Feat(medusa, medusa-js, medusa-react): order edit confirmation (#2264)

**what**

Support confirm of an order edit:

Upon confirmation, the items of the original order are detached and the items from the order edit are attached to the order.
The order total is recomputed with the correct total which can defer from the paid_total and refundable_amount (based on the paid_total)


**Tests**

- Unit tests medusa-js and medusa-react as well as the core
- Integration test of the confirmation flow which check that the order edit is properly confirmed and can be confirmed idempotently. Also validate the totals and that the order items correspond to the order edit items. Also validate the order totals.

FIXES CORE-498
This commit is contained in:
Adrien de Peretti
2022-09-29 10:00:48 +02:00
committed by GitHub
parent 87ad29dda4
commit 2be00007b2
22 changed files with 736 additions and 198 deletions

View File

@@ -1322,7 +1322,7 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => {
})
})
describe("POST /admin/order-edits/:id", () => {
describe("POST /admin/order-edits/:id/cancel", () => {
const cancellableEditId = IdMap.getId("order-edit-1")
const canceledEditId = IdMap.getId("order-edit-2")
const confirmedEditId = IdMap.getId("order-edit-3")
@@ -1387,7 +1387,6 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => {
{},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order_edit).toEqual(
expect.objectContaining({
@@ -1419,6 +1418,210 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => {
})
})
describe("POST /admin/order-edits/:id/confirm", () => {
let product, product2
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,
})
})
afterEach(async () => {
const db = useDb()
return await db.teardown()
})
it("confirms an order edit", async () => {
const api = useApi()
const region = await simpleRegionFactory(dbConnection, { tax_rate: 10 })
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}/payment-sessions`)
const completeRes = await api.post(`/store/carts/${cart.id}/complete`)
const order = completeRes.data.data
let response = await api.post(
`/admin/order-edits/`,
{
order_id: order.id,
internal_note: "This is an internal note",
},
adminHeaders
)
const orderEditId = response.data.order_edit.id
const itemToUpdate = response.data.order_edit.items.find(
(item) => item.original_item_id === lineItemId1
)
response = await api.post(
`/admin/order-edits/${orderEditId}/items/${itemToUpdate.id}`,
{ quantity: 2 },
adminHeaders
)
const orderEditItems = response.data.order_edit.items
response = await api.post(
`/admin/order-edits/${orderEditId}/confirm`,
{},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order_edit).toEqual(
expect.objectContaining({
id: orderEditId,
created_by: "admin_user",
confirmed_by: "admin_user",
confirmed_at: expect.any(String),
status: "confirmed",
discount_total: 0,
gift_card_total: 0,
gift_card_tax_total: 0,
shipping_total: 0,
subtotal: 3000,
tax_total: 300,
total: 3300,
})
)
response = await api.get(`/admin/orders/${order.id}`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.order).toEqual(
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
id: orderEditItems[0].id,
original_item_id: orderEditItems[0].original_item_id,
}),
expect.objectContaining({
id: orderEditItems[1].id,
original_item_id: orderEditItems[1].original_item_id,
}),
]),
shipping_total: 0,
discount_total: 0,
tax_total: 300,
refunded_total: 0,
gift_card_total: 0,
gift_card_tax_total: 0,
subtotal: 3000,
total: 3300,
paid_total: 2200,
refundable_amount: 2200,
})
)
})
it("confirms an already confirmed order edit", async () => {
const api = useApi()
const confirmedOrderEdit = await simpleOrderEditFactory(dbConnection, {
created_by: "admin_user",
confirmed_at: new Date(),
confirmed_by: "admin_user",
})
const response = await api.post(
`/admin/order-edits/${confirmedOrderEdit.id}/confirm`,
{},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order_edit).toEqual(
expect.objectContaining({
id: confirmedOrderEdit.id,
created_by: "admin_user",
confirmed_by: "admin_user",
confirmed_at: expect.any(String),
status: "confirmed",
})
)
})
it("confirms an already canceled order edit", async () => {
const api = useApi()
const canceledOrderEdit = await simpleOrderEditFactory(dbConnection, {
created_by: "admin_user",
canceled_at: new Date(),
canceled_by: "admin_user",
})
const err = await api
.post(
`/admin/order-edits/${canceledOrderEdit.id}/confirm`,
{},
adminHeaders
)
.catch((e) => e)
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual(
"Cannot confirm an order edit with status canceled"
)
})
it("confirms an already declined order edit", async () => {
const api = useApi()
const declinedOrderEdit = await simpleOrderEditFactory(dbConnection, {
created_by: "admin_user",
declined_at: new Date(),
declined_by: "admin_user",
})
const err = await api
.post(
`/admin/order-edits/${declinedOrderEdit.id}/confirm`,
{},
adminHeaders
)
.catch((e) => e)
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual(
"Cannot confirm an order edit with status declined"
)
})
})
describe("POST /admin/order-edits/:id/items/:item_id", () => {
let product, product2
const orderId = IdMap.getId("order-1")

View File

@@ -78,6 +78,14 @@ class AdminOrderEditsResource extends BaseResource {
return this.client.request("POST", path, undefined, {}, customHeaders)
}
confirm(
id: string,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminOrderEditsRes> {
const path = `/admin/order-edits/${id}/confirm`
return this.client.request("POST", path, undefined, {}, customHeaders)
}
updateLineItem(
orderEditId: string,
itemId: string,

View File

@@ -1695,12 +1695,29 @@ export const adminHandlers = [
})
)
}),
rest.post("/admin/order-edits/:id/cancel", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
order_edit: { ...fixtures.get("order_edit"), canceled_at: new Date(), status: 'canceled' },
order_edit: {
...fixtures.get("order_edit"),
canceled_at: new Date(),
status: "canceled",
},
})
)
}),
rest.post("/admin/order-edits/:id/confirm", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
order_edit: {
...fixtures.get("order_edit"),
confirmed_at: new Date(),
status: "confirmed",
},
})
)
}),

View File

@@ -153,24 +153,36 @@ export const useAdminRequestOrderEditConfirmation = (
)
}
export const useAdminCancelOrderEdit = (
id: string,
options?: UseMutationOptions<
Response<AdminOrderEditsRes>,
Error
>
options?: UseMutationOptions<Response<AdminOrderEditsRes>, Error>
) => {
const { client } = useMedusa()
const queryClient = useQueryClient()
return useMutation(
() =>
client.admin.orderEdits.cancel(id),
() => client.admin.orderEdits.cancel(id),
buildOptions(
queryClient,
[adminOrderEditsKeys.lists(), adminOrderEditsKeys.detail(id)],
options
)
)
}
}
export const useAdminConfirmOrderEdit = (
id: string,
options?: UseMutationOptions<Response<AdminOrderEditsRes>, Error>
) => {
const { client } = useMedusa()
const queryClient = useQueryClient()
return useMutation(
() => client.admin.orderEdits.confirm(id),
buildOptions(
queryClient,
[adminOrderEditsKeys.lists(), adminOrderEditsKeys.detail(id)],
options
)
)
}

View File

@@ -1,6 +1,7 @@
import { renderHook } from "@testing-library/react-hooks"
import {
useAdminCancelOrderEdit,
useAdminConfirmOrderEdit,
useAdminCreateOrderEdit,
useAdminDeleteOrderEdit,
useAdminDeleteOrderEditItemChange,
@@ -220,3 +221,29 @@ describe("useAdminCancelOrderEdit hook", () => {
)
})
})
describe("useAdminConfirmOrderEdit hook", () => {
test("confirm an order edit", async () => {
const { result, waitFor } = renderHook(
() => useAdminConfirmOrderEdit(fixtures.get("order_edit").id),
{
wrapper: createWrapper(),
}
)
result.current.mutate()
await waitFor(() => result.current.isSuccess)
expect(result.current.data.response.status).toEqual(200)
expect(result.current.data).toEqual(
expect.objectContaining({
order_edit: {
...fixtures.get("order_edit"),
confirmed_at: expect.any(String),
status: "confirmed",
},
})
)
})
})

View File

@@ -9,14 +9,18 @@ describe("POST /admin/order-edits/:id/cancel", () => {
let subject
beforeAll(async () => {
subject = await request("POST", `/admin/order-edits/${orderEditId}/cancel`, {
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
subject = await request(
"POST",
`/admin/order-edits/${orderEditId}/cancel`,
{
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
},
flags: [OrderEditingFeatureFlag],
})
flags: [OrderEditingFeatureFlag],
}
)
})
afterAll(() => {
@@ -25,7 +29,9 @@ describe("POST /admin/order-edits/:id/cancel", () => {
it("calls orderService cancel", () => {
expect(orderEditServiceMock.cancel).toHaveBeenCalledTimes(1)
expect(orderEditServiceMock.cancel).toHaveBeenCalledWith(orderEditId, {loggedInUser: IdMap.getId("admin_user")})
expect(orderEditServiceMock.cancel).toHaveBeenCalledWith(orderEditId, {
loggedInUserId: IdMap.getId("admin_user"),
})
})
it("returns 200", () => {
@@ -33,11 +39,13 @@ describe("POST /admin/order-edits/:id/cancel", () => {
})
it("returns cancel result", () => {
expect(subject.body.order_edit).toEqual(expect.objectContaining({
id: orderEditId,
canceled_at: expect.any(String),
status: 'canceled'
}))
expect(subject.body.order_edit).toEqual(
expect.objectContaining({
id: orderEditId,
canceled_at: expect.any(String),
status: "canceled",
})
)
})
})
})

View File

@@ -0,0 +1,52 @@
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/confirm", () => {
describe("confirms an order edit", () => {
const orderEditId = IdMap.getId("testConfirmOrderEdit")
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/order-edits/${orderEditId}/confirm`,
{
adminSession: {
jwt: {
userId: "admin_user",
},
},
flags: [OrderEditingFeatureFlag],
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls orderService confirm", () => {
expect(orderEditServiceMock.confirm).toHaveBeenCalledTimes(1)
expect(orderEditServiceMock.confirm).toHaveBeenCalledWith(orderEditId, {
loggedInUserId: "admin_user",
})
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns confirm result", () => {
expect(subject.body.order_edit).toEqual(
expect.objectContaining({
id: orderEditId,
confirmed_at: expect.any(String),
confirmed_by: "admin_user",
status: "confirmed",
})
)
})
})
})

View File

@@ -31,7 +31,7 @@ describe("GET /admin/order-edits/:id", () => {
expect(orderEditServiceMock.requestConfirmation).toHaveBeenCalledTimes(1)
expect(orderEditServiceMock.requestConfirmation).toHaveBeenCalledWith(
orderEditId,
{ loggedInUser: IdMap.getId("admin_user") }
{ loggedInUserId: IdMap.getId("admin_user") }
)
})

View File

@@ -1,7 +1,10 @@
import { Request, Response } from "express"
import { OrderEditService } from "../../../../services"
import { IsOptional, IsString } from "class-validator"
import { EntityManager } from "typeorm"
import {
defaultOrderEditFields,
defaultOrderEditRelations,
} from "../../../../types/order-edit"
/**
* @oas [post] /order-edits/{id}/cancel
@@ -64,10 +67,13 @@ export default async (req: Request, res: Response) => {
await manager.transaction(async (transactionManager) => {
await orderEditService
.withTransaction(transactionManager)
.cancel(id, { loggedInUser: userId })
.cancel(id, { loggedInUserId: userId })
})
const orderEdit = await orderEditService.retrieve(id)
const orderEdit = await orderEditService.retrieve(id, {
select: defaultOrderEditFields,
relations: defaultOrderEditRelations,
})
return res.json({ order_edit: orderEdit })
}

View File

@@ -0,0 +1,80 @@
import { Request, Response } from "express"
import { OrderEditService } from "../../../../services"
import { EntityManager } from "typeorm"
import {
defaultOrderEditFields,
defaultOrderEditRelations,
} from "../../../../types/order-edit"
/**
* @oas [post] /order-edits/{id}/confirm
* operationId: "PostOrderEditsOrderEditConfirm"
* summary: "Confirms an OrderEdit"
* description: "Confirms an OrderEdit."
* x-authenticated: true
* parameters:
* - (path) id=* {string} The ID of the order edit.
* 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.orderEdit.confirm(orderEditId)
* .then(({ order_edit }) => {
* console.log(order_edit.id)
* })
* - lang: Shell
* label: cURL
* source: |
* curl --location --request POST 'https://medusa-url.com/admin/order-edits/:id/confirm' \
* --header 'Authorization: Bearer {api_token}'
* 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"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req: Request, res: Response) => {
const { id } = req.params
const orderEditService = req.scope.resolve(
"orderEditService"
) as OrderEditService
const manager = req.scope.resolve("manager") as EntityManager
const userId = req.user?.id ?? req.user?.userId
await manager.transaction(async (transactionManager) => {
await orderEditService
.withTransaction(transactionManager)
.confirm(id, { loggedInUserId: userId })
})
let orderEdit = await orderEditService.retrieve(id, {
select: defaultOrderEditFields,
relations: defaultOrderEditRelations,
})
orderEdit = await orderEditService.decorateTotals(orderEdit)
return res.json({ order_edit: orderEdit })
}

View File

@@ -59,6 +59,11 @@ export default (app) => {
middlewares.wrap(require("./add-line-item").default)
)
route.post(
"/:id/confirm",
middlewares.wrap(require("./confirm-order-edit").default)
)
route.delete("/:id", middlewares.wrap(require("./delete-order-edit").default))
route.delete(

View File

@@ -65,7 +65,7 @@ export default async (req, res) => {
await manager.transaction(async (transactionManager) => {
await orderEditService
.withTransaction(transactionManager)
.requestConfirmation(id, { loggedInUser })
.requestConfirmation(id, { loggedInUserId: loggedInUser })
})
const orderEdit = await orderEditService.retrieve(id, {

View File

@@ -31,7 +31,7 @@ describe("GET /store/order-edits/:id", () => {
expect(orderEditServiceMock.decline).toHaveBeenCalledTimes(1)
expect(orderEditServiceMock.decline).toHaveBeenCalledWith(orderEditId, {
declinedReason: "test",
loggedInUser: undefined,
loggedInUserId: undefined,
})
expect(orderEditServiceMock.decorateTotals).toHaveBeenCalledTimes(1)
})

View File

@@ -72,7 +72,7 @@ export default async (req: Request, res: Response) => {
await manager.transaction(async (manager) => {
await orderEditService.withTransaction(manager).decline(id, {
declinedReason: validatedBody.declined_reason,
loggedInUser: userId,
loggedInUserId: userId,
})
})

View File

@@ -54,7 +54,7 @@ export class LineItem extends BaseEntity {
@Index()
@Column({ nullable: true })
order_id: string
order_id: string | null
@ManyToOne(() => Order, (order) => order.items)
@JoinColumn({ name: "order_id" })

View File

@@ -1,22 +1,20 @@
import { IdMap } from "medusa-test-utils"
export const orderEdits = {
testCreatedOrder: {
id: IdMap.getId("testCreatedOrder"),
order_id: "empty-id",
internal_note: "internal note",
declined_reason: null,
declined_at: null,
declined_by: null,
canceled_at: null,
canceled_by: null,
requested_at: null,
requested_by: null,
created_at: new Date(),
created_by: "admin_user",
confirmed_at: null,
confirmed_by: null,
},
export const orderEdit = {
id: IdMap.getId("testCreatedOrder"),
order_id: "empty-id",
internal_note: "internal note",
declined_reason: null,
declined_at: null,
declined_by: null,
canceled_at: null,
canceled_by: null,
requested_at: null,
requested_by: null,
created_at: new Date(),
created_by: "admin_user",
confirmed_at: null,
confirmed_by: null,
}
const computeLineItems = (orderEdit) => ({
@@ -48,11 +46,20 @@ export const orderEditServiceMock = {
},
retrieve: jest.fn().mockImplementation((orderId) => {
if (orderId === IdMap.getId("testCreatedOrder")) {
return Promise.resolve(orderEdits.testCreatedOrder)
return Promise.resolve(orderEdit)
}
if (orderId === IdMap.getId("testConfirmOrderEdit")) {
return Promise.resolve({
...orderEdit,
id: IdMap.getId("testConfirmOrderEdit"),
confirmed_at: new Date(),
confirmed_by: "admin_user",
status: "confirmed",
})
}
if (orderId === IdMap.getId("testDeclineOrderEdit")) {
return Promise.resolve({
...orderEdits.testCreatedOrder,
...orderEdit,
id: IdMap.getId("testDeclineOrderEdit"),
declined_reason: "Wrong size",
declined_at: new Date(),
@@ -60,7 +67,7 @@ export const orderEditServiceMock = {
}
if (orderId === IdMap.getId("testCancelOrderEdit")) {
return Promise.resolve({
...orderEdits.testCreatedOrder,
...orderEdit,
id: orderId,
canceled_at: new Date(),
status: "canceled",
@@ -68,7 +75,7 @@ export const orderEditServiceMock = {
}
if (orderId === IdMap.getId("testRequestOrder")) {
return Promise.resolve({
...orderEdits.testCreatedOrder,
...orderEdit,
id: IdMap.getId("testRequestOrder"),
requested_by: IdMap.getId("admin_user"),
requested_at: new Date(),
@@ -111,7 +118,7 @@ export const orderEditServiceMock = {
}),
requestConfirmation: jest.fn().mockImplementation((orderEditId, userId) => {
return Promise.resolve({
...orderEdits.testCreatedOrder,
...orderEdit,
id: orderEditId,
requested_at: new Date(),
requested_by: userId,
@@ -120,6 +127,9 @@ export const orderEditServiceMock = {
cancel: jest.fn().mockImplementation(() => {
return Promise.resolve({})
}),
confirm: jest.fn().mockImplementation(() => {
return Promise.resolve({})
}),
updateLineItem: jest.fn().mockImplementation((_) => {
return Promise.resolve()
}),

View File

@@ -4,6 +4,7 @@ import LineItemService from "../line-item"
import { PricingServiceMock } from "../__mocks__/pricing"
import { ProductVariantServiceMock } from "../__mocks__/product-variant"
import { RegionServiceMock } from "../__mocks__/region"
;[true, false].forEach((isTaxInclusiveEnabled) => {
describe(`tax inclusive flag set to: ${isTaxInclusiveEnabled}`, () => {
describe("LineItemService", () => {
@@ -167,21 +168,23 @@ import { RegionServiceMock } from "../__mocks__/region"
describe("update", () => {
const lineItemRepository = MockRepository({
findOne: () =>
Promise.resolve({
id: IdMap.getId("test-line-item"),
variant_id: IdMap.getId("test-variant"),
variant: {
id: IdMap.getId("test-variant"),
title: "Test variant",
find: () =>
Promise.resolve([
{
id: IdMap.getId("test-line-item"),
variant_id: IdMap.getId("test-variant"),
variant: {
id: IdMap.getId("test-variant"),
title: "Test variant",
},
cart_id: IdMap.getId("test-cart"),
title: "Test product",
description: "Test variant",
thumbnail: "",
unit_price: 50,
quantity: 1,
},
cart_id: IdMap.getId("test-cart"),
title: "Test product",
description: "Test variant",
thumbnail: "",
unit_price: 50,
quantity: 1,
}),
]),
})
const lineItemService = new LineItemService({
@@ -200,21 +203,23 @@ import { RegionServiceMock } from "../__mocks__/region"
})
expect(lineItemRepository.save).toHaveBeenCalledTimes(1)
expect(lineItemRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("test-line-item"),
variant_id: IdMap.getId("test-variant"),
variant: {
id: IdMap.getId("test-variant"),
title: "Test variant",
expect(lineItemRepository.save).toHaveBeenCalledWith([
{
id: IdMap.getId("test-line-item"),
variant_id: IdMap.getId("test-variant"),
variant: {
id: IdMap.getId("test-variant"),
title: "Test variant",
},
cart_id: IdMap.getId("test-cart"),
title: "Test product",
description: "Test variant",
thumbnail: "",
unit_price: 50,
quantity: 2,
has_shipping: true,
},
cart_id: IdMap.getId("test-cart"),
title: "Test product",
description: "Test variant",
thumbnail: "",
unit_price: 50,
quantity: 2,
has_shipping: true,
})
])
})
it("successfully updates a line item with metadata", async () => {
@@ -225,23 +230,25 @@ import { RegionServiceMock } from "../__mocks__/region"
})
expect(lineItemRepository.save).toHaveBeenCalledTimes(1)
expect(lineItemRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("test-line-item"),
variant_id: IdMap.getId("test-variant"),
variant: {
id: IdMap.getId("test-variant"),
title: "Test variant",
expect(lineItemRepository.save).toHaveBeenCalledWith([
{
id: IdMap.getId("test-line-item"),
variant_id: IdMap.getId("test-variant"),
variant: {
id: IdMap.getId("test-variant"),
title: "Test variant",
},
cart_id: IdMap.getId("test-cart"),
title: "Test product",
description: "Test variant",
thumbnail: "",
unit_price: 50,
quantity: 1,
metadata: {
testKey: "testValue",
},
},
cart_id: IdMap.getId("test-cart"),
title: "Test product",
description: "Test variant",
thumbnail: "",
unit_price: 50,
quantity: 1,
metadata: {
testKey: "testValue",
},
})
])
})
})
describe("delete", () => {

View File

@@ -258,7 +258,7 @@ describe("OrderEditService", () => {
IdMap.getId("requested-order-edit"),
{
declinedReason: "I requested a different color for the new product",
loggedInUser: "admin_user",
loggedInUserId: "admin_user",
}
)
@@ -276,7 +276,7 @@ describe("OrderEditService", () => {
await expect(
orderEditService.decline(IdMap.getId("confirmed-order-edit"), {
declinedReason: "I requested a different color for the new product",
loggedInUser: "admin_user",
loggedInUserId: "admin_user",
})
).rejects.toThrowError(
"Cannot decline an order edit with status confirmed."
@@ -288,7 +288,7 @@ describe("OrderEditService", () => {
IdMap.getId("declined-order-edit"),
{
declinedReason: "I requested a different color for the new product",
loggedInUser: "admin_user",
loggedInUserId: "admin_user",
}
)
@@ -312,7 +312,7 @@ describe("OrderEditService", () => {
beforeEach(async () => {
result = await orderEditService.requestConfirmation(orderEditId, {
loggedInUser: userId,
loggedInUserId: userId,
})
})
@@ -344,7 +344,7 @@ describe("OrderEditService", () => {
beforeEach(async () => {
result = await orderEditService.requestConfirmation(orderEditId, {
loggedInUser: userId,
loggedInUserId: userId,
})
})
@@ -358,7 +358,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, { loggedInUserId: userId })
expect(orderEditRepository.save).toHaveBeenCalledWith({
...orderEditWithChanges,
@@ -402,6 +402,39 @@ describe("OrderEditService", () => {
}
)
})
describe("confirm", () => {
it("confirms an order edit", async () => {
const id = IdMap.getId("order-edit-with-changes")
const userId = IdMap.getId("user-id")
await orderEditService.confirm(id, { loggedInUserId: userId })
expect(orderEditRepository.save).toHaveBeenCalledWith({
...orderEditWithChanges,
confirmed_by: userId,
confirmed_at: expect.any(Date),
})
expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1)
expect(EventBusServiceMock.emit).toHaveBeenCalledWith(
OrderEditService.Events.CONFIRMED,
{ id }
)
})
it("Returns early in case of an already confirmed order edit", async () => {
const id = IdMap.getId("confirmed-order-edit")
const userId = IdMap.getId("user-id")
const result = await orderEditService.confirm(id, userId)
expect(result).toEqual(expect.objectContaining({ status: "confirmed" }))
expect(orderEditRepository.save).toHaveBeenCalledTimes(0)
expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(0)
})
})
})
it("should add a line item to an order edit", async () => {

View File

@@ -1838,6 +1838,9 @@ class CartService extends TransactionBaseService {
relations: ["countries"],
})
const lineItemServiceTx =
this.lineItemService_.withTransaction(transactionManager)
cart.items = (
await Promise.all(
cart.items.map(async (item) => {
@@ -1856,21 +1859,19 @@ class CartService extends TransactionBaseService {
availablePrice !== undefined &&
availablePrice.calculatedPrice !== null
) {
return this.lineItemService_
.withTransaction(transactionManager)
.update(item.id, {
has_shipping: false,
unit_price: availablePrice.calculatedPrice,
})
return lineItemServiceTx.update(item.id, {
has_shipping: false,
unit_price: availablePrice.calculatedPrice,
})
} else {
await this.lineItemService_
.withTransaction(transactionManager)
.delete(item.id)
await lineItemServiceTx.delete(item.id)
return
}
})
)
).filter((item): item is LineItem => !!item)
)
.flat()
.filter((item): item is LineItem => !!item)
}
}

View File

@@ -6,7 +6,7 @@ import { DeepPartial } from "typeorm/common/DeepPartial"
import { CartRepository } from "../repositories/cart"
import { LineItemRepository } from "../repositories/line-item"
import { LineItemTaxLineRepository } from "../repositories/line-item-tax-line"
import { Cart, LineItemTaxLine, LineItem, LineItemAdjustment } from "../models"
import { Cart, LineItem, LineItemAdjustment, LineItemTaxLine } from "../models"
import { FindConfig, Selector } from "../types/common"
import { FlagRouter } from "../utils/flag-router"
import LineItemAdjustmentService from "./line-item-adjustment"
@@ -18,6 +18,7 @@ import {
ProductVariantService,
RegionService,
} from "./index"
import { setMetadata } from "../utils"
type InjectedDependencies = {
manager: EntityManager
@@ -310,11 +311,14 @@ class LineItemService extends BaseService {
/**
* Updates a line item
* @param {string} id - the id of the line item to update
* @param {Partial<LineItem>} data - the properties to update on line item
* @return {Promise<LineItem>} the update line item
* @param idOrSelector - the id or selector of the line item(s) to update
* @param data - the properties to update the line item(s)
* @return the updated line item(s)
*/
async update(id: string, data: Partial<LineItem>): Promise<LineItem> {
async update(
idOrSelector: string | Selector<LineItem>,
data: Partial<LineItem>
): Promise<LineItem[]> {
const { metadata, ...rest } = data
return await this.atomicPhase_(
@@ -323,17 +327,34 @@ class LineItemService extends BaseService {
this.lineItemRepository_
)
const lineItem = await this.retrieve(id).then((lineItem) => {
const lineItemMetadata = metadata
? this.setMetadata_(lineItem, metadata)
: lineItem.metadata
const selector =
typeof idOrSelector === "string" ? { id: idOrSelector } : idOrSelector
return Object.assign(lineItem, {
let lineItems = await this.list(selector)
if (!lineItems.length) {
const selectorConstraints = Object.entries(selector)
.map(([key, value]) => `${key}: ${value}`)
.join(", ")
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Line item with ${selectorConstraints} was not found`
)
}
lineItems = lineItems.map((item) => {
const lineItemMetadata = metadata
? setMetadata(item, metadata)
: item.metadata
return Object.assign(item, {
...rest,
metadata: lineItemMetadata,
})
})
return await lineItemRepository.save(lineItem)
return await lineItemRepository.save(lineItems)
}
)
}

View File

@@ -1,4 +1,4 @@
import { EntityManager, IsNull } from "typeorm"
import { DeepPartial, EntityManager, IsNull } from "typeorm"
import { MedusaError } from "medusa-core-utils"
import { FindConfig } from "../types/common"
@@ -24,7 +24,6 @@ import {
import {
AddOrderEditLineItemInput,
CreateOrderEditInput,
UpdateOrderEditInput,
} from "../types/order-edit"
type InjectedDependencies = {
@@ -47,6 +46,7 @@ export default class OrderEditService extends TransactionBaseService {
DECLINED: "order-edit.declined",
REQUESTED: "order-edit.requested",
CANCELED: "order-edit.canceled",
CONFIRMED: "order-edit.confirmed",
}
protected readonly manager_: EntityManager
@@ -109,27 +109,6 @@ export default class OrderEditService extends TransactionBaseService {
return orderEdit
}
protected async retrieveActive(
orderId: string,
config: FindConfig<OrderEdit> = {}
): Promise<OrderEdit | undefined> {
const manager = this.transactionManager_ ?? this.manager_
const orderEditRepository = manager.getCustomRepository(
this.orderEditRepository_
)
const query = buildQuery(
{
order_id: orderId,
confirmed_at: IsNull(),
canceled_at: IsNull(),
declined_at: IsNull(),
},
config
)
return await orderEditRepository.findOne(query)
}
/**
* Compute and return the different totals from the order edit id
* @param orderEditId
@@ -235,7 +214,7 @@ export default class OrderEditService extends TransactionBaseService {
async update(
orderEditId: string,
data: UpdateOrderEditInput
data: DeepPartial<OrderEdit>
): Promise<OrderEdit> {
return await this.atomicPhase_(async (manager) => {
const orderEditRepo = manager.getCustomRepository(
@@ -290,7 +269,7 @@ export default class OrderEditService extends TransactionBaseService {
orderEditId: string,
context: {
declinedReason?: string
loggedInUser?: string
loggedInUserId?: string
}
): Promise<OrderEdit> {
return await this.atomicPhase_(async (manager) => {
@@ -298,7 +277,7 @@ export default class OrderEditService extends TransactionBaseService {
this.orderEditRepository_
)
const { loggedInUser, declinedReason } = context
const { loggedInUserId, declinedReason } = context
const orderEdit = await this.retrieve(orderEditId)
@@ -314,7 +293,7 @@ export default class OrderEditService extends TransactionBaseService {
}
orderEdit.declined_at = new Date()
orderEdit.declined_by = loggedInUser
orderEdit.declined_by = loggedInUserId
orderEdit.declined_reason = declinedReason
const result = await orderEditRepo.save(orderEdit)
@@ -573,7 +552,7 @@ export default class OrderEditService extends TransactionBaseService {
async requestConfirmation(
orderEditId: string,
context: {
loggedInUser?: string
loggedInUserId?: string
} = {}
): Promise<OrderEdit> {
return await this.atomicPhase_(async (manager) => {
@@ -598,7 +577,7 @@ export default class OrderEditService extends TransactionBaseService {
}
orderEdit.requested_at = new Date()
orderEdit.requested_by = context.loggedInUser
orderEdit.requested_by = context.loggedInUserId
orderEdit = await orderEditRepo.save(orderEdit)
@@ -610,6 +589,118 @@ export default class OrderEditService extends TransactionBaseService {
})
}
async cancel(
orderEditId: string,
context: { loggedInUserId?: string } = {}
): Promise<OrderEdit> {
return await this.atomicPhase_(async (manager) => {
const orderEditRepository = manager.getCustomRepository(
this.orderEditRepository_
)
const orderEdit = await this.retrieve(orderEditId)
if (orderEdit.status === OrderEditStatus.CANCELED) {
return orderEdit
}
if (
[OrderEditStatus.CONFIRMED, OrderEditStatus.DECLINED].includes(
orderEdit.status
)
) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Cannot cancel order edit with status ${orderEdit.status}`
)
}
orderEdit.canceled_at = new Date()
orderEdit.canceled_by = context.loggedInUserId
const saved = await orderEditRepository.save(orderEdit)
await this.eventBusService_
.withTransaction(manager)
.emit(OrderEditService.Events.CANCELED, { id: orderEditId })
return saved
})
}
async confirm(
orderEditId: string,
context: { loggedInUserId?: string } = {}
): Promise<OrderEdit> {
return await this.atomicPhase_(async (manager) => {
const orderEditRepository = manager.getCustomRepository(
this.orderEditRepository_
)
let orderEdit = await this.retrieve(orderEditId)
if (
[OrderEditStatus.CANCELED, OrderEditStatus.DECLINED].includes(
orderEdit.status
)
) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Cannot confirm an order edit with status ${orderEdit.status}`
)
}
if (orderEdit.status === OrderEditStatus.CONFIRMED) {
return orderEdit
}
const lineItemServiceTx = this.lineItemService_.withTransaction(manager)
await Promise.all([
lineItemServiceTx.update(
{ order_id: orderEdit.order_id },
{ order_id: null }
),
lineItemServiceTx.update(
{ order_edit_id: orderEditId },
{ order_id: orderEdit.order_id }
),
])
orderEdit.confirmed_at = new Date()
orderEdit.confirmed_by = context.loggedInUserId
orderEdit = await orderEditRepository.save(orderEdit)
await this.eventBusService_
.withTransaction(manager)
.emit(OrderEditService.Events.CONFIRMED, { id: orderEditId })
return orderEdit
})
}
protected async retrieveActive(
orderId: string,
config: FindConfig<OrderEdit> = {}
): Promise<OrderEdit | undefined> {
const manager = this.transactionManager_ ?? this.manager_
const orderEditRepository = manager.getCustomRepository(
this.orderEditRepository_
)
const query = buildQuery(
{
order_id: orderId,
confirmed_at: IsNull(),
canceled_at: IsNull(),
declined_at: IsNull(),
},
config
)
return await orderEditRepository.findOne(query)
}
protected async deleteClonedItems(orderEditId: string): Promise<void> {
const manager = this.transactionManager_ ?? this.manager_
const lineItemServiceTx = this.lineItemService_.withTransaction(manager)
@@ -647,45 +738,6 @@ export default class OrderEditService extends TransactionBaseService {
)
}
async cancel(
orderEditId: string,
context: { loggedInUser?: string } = {}
): Promise<OrderEdit> {
return await this.atomicPhase_(async (manager) => {
const orderEditRepository = manager.getCustomRepository(
this.orderEditRepository_
)
const orderEdit = await this.retrieve(orderEditId)
if (orderEdit.status === OrderEditStatus.CANCELED) {
return orderEdit
}
if (
[OrderEditStatus.CONFIRMED, OrderEditStatus.DECLINED].includes(
orderEdit.status
)
) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Cannot cancel order edit with status ${orderEdit.status}`
)
}
orderEdit.canceled_at = new Date()
orderEdit.canceled_by = context.loggedInUser
const saved = await orderEditRepository.save(orderEdit)
await this.eventBusService_
.withTransaction(manager)
.emit(OrderEditService.Events.CANCELED, { id: orderEditId })
return saved
})
}
private static isOrderEditActive(orderEdit: OrderEdit): boolean {
return !(
orderEdit.status === OrderEditStatus.CONFIRMED ||

View File

@@ -1,9 +1,5 @@
import { OrderEdit, OrderEditItemChangeType } from "../models"
export type UpdateOrderEditInput = {
internal_note?: string
}
export type CreateOrderEditInput = {
order_id: string
internal_note?: string