diff --git a/integration-tests/api/__tests__/admin/order-edit.js b/integration-tests/api/__tests__/admin/order-edit.js index 19d35cdab8..05c7b9ad53 100644 --- a/integration-tests/api/__tests__/admin/order-edit.js +++ b/integration-tests/api/__tests__/admin/order-edit.js @@ -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") diff --git a/packages/medusa-js/src/resources/admin/order-edits.ts b/packages/medusa-js/src/resources/admin/order-edits.ts index 4c88586bd0..61f90a39b2 100644 --- a/packages/medusa-js/src/resources/admin/order-edits.ts +++ b/packages/medusa-js/src/resources/admin/order-edits.ts @@ -78,6 +78,14 @@ class AdminOrderEditsResource extends BaseResource { return this.client.request("POST", path, undefined, {}, customHeaders) } + confirm( + id: string, + customHeaders: Record = {} + ): ResponsePromise { + const path = `/admin/order-edits/${id}/confirm` + return this.client.request("POST", path, undefined, {}, customHeaders) + } + updateLineItem( orderEditId: string, itemId: string, diff --git a/packages/medusa-react/mocks/handlers/admin.ts b/packages/medusa-react/mocks/handlers/admin.ts index d24ed25867..5ac689b34d 100644 --- a/packages/medusa-react/mocks/handlers/admin.ts +++ b/packages/medusa-react/mocks/handlers/admin.ts @@ -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", + }, }) ) }), diff --git a/packages/medusa-react/src/hooks/admin/order-edits/mutations.ts b/packages/medusa-react/src/hooks/admin/order-edits/mutations.ts index 6cdddf370b..33043c2651 100644 --- a/packages/medusa-react/src/hooks/admin/order-edits/mutations.ts +++ b/packages/medusa-react/src/hooks/admin/order-edits/mutations.ts @@ -153,24 +153,36 @@ export const useAdminRequestOrderEditConfirmation = ( ) } - export const useAdminCancelOrderEdit = ( id: string, - options?: UseMutationOptions< - Response, - Error - > + options?: UseMutationOptions, 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 ) ) -} \ No newline at end of file +} + +export const useAdminConfirmOrderEdit = ( + id: string, + options?: UseMutationOptions, Error> +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + + return useMutation( + () => client.admin.orderEdits.confirm(id), + buildOptions( + queryClient, + [adminOrderEditsKeys.lists(), adminOrderEditsKeys.detail(id)], + options + ) + ) +} diff --git a/packages/medusa-react/test/hooks/admin/order-edits/mutations.test.ts b/packages/medusa-react/test/hooks/admin/order-edits/mutations.test.ts index 5dd792e15c..9d8bdb22f3 100644 --- a/packages/medusa-react/test/hooks/admin/order-edits/mutations.test.ts +++ b/packages/medusa-react/test/hooks/admin/order-edits/mutations.test.ts @@ -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", + }, + }) + ) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/order-edits/__tests__/cancel-order-edit.ts b/packages/medusa/src/api/routes/admin/order-edits/__tests__/cancel-order-edit.ts index 4ad47466ee..f9336fee66 100644 --- a/packages/medusa/src/api/routes/admin/order-edits/__tests__/cancel-order-edit.ts +++ b/packages/medusa/src/api/routes/admin/order-edits/__tests__/cancel-order-edit.ts @@ -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", + }) + ) }) }) }) diff --git a/packages/medusa/src/api/routes/admin/order-edits/__tests__/confirm-order-edit.ts b/packages/medusa/src/api/routes/admin/order-edits/__tests__/confirm-order-edit.ts new file mode 100644 index 0000000000..12d253398e --- /dev/null +++ b/packages/medusa/src/api/routes/admin/order-edits/__tests__/confirm-order-edit.ts @@ -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", + }) + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/order-edits/__tests__/request-confirmation.ts b/packages/medusa/src/api/routes/admin/order-edits/__tests__/request-confirmation.ts index e361db7c5a..bc874857a7 100644 --- a/packages/medusa/src/api/routes/admin/order-edits/__tests__/request-confirmation.ts +++ b/packages/medusa/src/api/routes/admin/order-edits/__tests__/request-confirmation.ts @@ -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") } ) }) diff --git a/packages/medusa/src/api/routes/admin/order-edits/cancel-order-edit.ts b/packages/medusa/src/api/routes/admin/order-edits/cancel-order-edit.ts index a74bfb630e..4e85e887b8 100644 --- a/packages/medusa/src/api/routes/admin/order-edits/cancel-order-edit.ts +++ b/packages/medusa/src/api/routes/admin/order-edits/cancel-order-edit.ts @@ -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 }) } diff --git a/packages/medusa/src/api/routes/admin/order-edits/confirm-order-edit.ts b/packages/medusa/src/api/routes/admin/order-edits/confirm-order-edit.ts new file mode 100644 index 0000000000..b477e6b873 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/order-edits/confirm-order-edit.ts @@ -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 }) +} diff --git a/packages/medusa/src/api/routes/admin/order-edits/index.ts b/packages/medusa/src/api/routes/admin/order-edits/index.ts index 6b1dcd7142..fe0bd999f3 100644 --- a/packages/medusa/src/api/routes/admin/order-edits/index.ts +++ b/packages/medusa/src/api/routes/admin/order-edits/index.ts @@ -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( diff --git a/packages/medusa/src/api/routes/admin/order-edits/request-confirmation.ts b/packages/medusa/src/api/routes/admin/order-edits/request-confirmation.ts index 0e44d4a973..6ff9e46e3e 100644 --- a/packages/medusa/src/api/routes/admin/order-edits/request-confirmation.ts +++ b/packages/medusa/src/api/routes/admin/order-edits/request-confirmation.ts @@ -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, { diff --git a/packages/medusa/src/api/routes/store/order-edits/__tests__/decline-order-edit.ts b/packages/medusa/src/api/routes/store/order-edits/__tests__/decline-order-edit.ts index 54322e98eb..93eb2fa3f8 100644 --- a/packages/medusa/src/api/routes/store/order-edits/__tests__/decline-order-edit.ts +++ b/packages/medusa/src/api/routes/store/order-edits/__tests__/decline-order-edit.ts @@ -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) }) diff --git a/packages/medusa/src/api/routes/store/order-edits/decline-order-edit.ts b/packages/medusa/src/api/routes/store/order-edits/decline-order-edit.ts index 355a7c1514..d9d07b6874 100644 --- a/packages/medusa/src/api/routes/store/order-edits/decline-order-edit.ts +++ b/packages/medusa/src/api/routes/store/order-edits/decline-order-edit.ts @@ -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, }) }) diff --git a/packages/medusa/src/models/line-item.ts b/packages/medusa/src/models/line-item.ts index a38ab1ad93..faccbf8aeb 100644 --- a/packages/medusa/src/models/line-item.ts +++ b/packages/medusa/src/models/line-item.ts @@ -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" }) diff --git a/packages/medusa/src/services/__mocks__/order-edit.js b/packages/medusa/src/services/__mocks__/order-edit.js index 9b8b52be04..6741a2e6a4 100644 --- a/packages/medusa/src/services/__mocks__/order-edit.js +++ b/packages/medusa/src/services/__mocks__/order-edit.js @@ -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() }), diff --git a/packages/medusa/src/services/__tests__/line-item.js b/packages/medusa/src/services/__tests__/line-item.js index 306c5ac25a..048b7e8ca5 100644 --- a/packages/medusa/src/services/__tests__/line-item.js +++ b/packages/medusa/src/services/__tests__/line-item.js @@ -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", () => { diff --git a/packages/medusa/src/services/__tests__/order-edit.ts b/packages/medusa/src/services/__tests__/order-edit.ts index 90cf0ed91c..603d218f86 100644 --- a/packages/medusa/src/services/__tests__/order-edit.ts +++ b/packages/medusa/src/services/__tests__/order-edit.ts @@ -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 () => { diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index 4097375ce4..114bacc802 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -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) } } diff --git a/packages/medusa/src/services/line-item.ts b/packages/medusa/src/services/line-item.ts index e33b54810f..d1088d9785 100644 --- a/packages/medusa/src/services/line-item.ts +++ b/packages/medusa/src/services/line-item.ts @@ -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} data - the properties to update on line item - * @return {Promise} 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): Promise { + async update( + idOrSelector: string | Selector, + data: Partial + ): Promise { 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) } ) } diff --git a/packages/medusa/src/services/order-edit.ts b/packages/medusa/src/services/order-edit.ts index 2a6f5a249d..411be85327 100644 --- a/packages/medusa/src/services/order-edit.ts +++ b/packages/medusa/src/services/order-edit.ts @@ -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 = {} - ): Promise { - 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 ): Promise { 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 { 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 { 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 { + 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 { + 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 = {} + ): Promise { + 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 { 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 { - 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 || diff --git a/packages/medusa/src/types/order-edit.ts b/packages/medusa/src/types/order-edit.ts index 993f0c5eba..7d759614b3 100644 --- a/packages/medusa/src/types/order-edit.ts +++ b/packages/medusa/src/types/order-edit.ts @@ -1,9 +1,5 @@ import { OrderEdit, OrderEditItemChangeType } from "../models" -export type UpdateOrderEditInput = { - internal_note?: string -} - export type CreateOrderEditInput = { order_id: string internal_note?: string