From 2be00007b251e0297952bf4f7294420f88acdb0f Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Thu, 29 Sep 2022 10:00:48 +0200 Subject: [PATCH] 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 --- .../api/__tests__/admin/order-edit.js | 207 +++++++++++++++++- .../src/resources/admin/order-edits.ts | 8 + packages/medusa-react/mocks/handlers/admin.ts | 21 +- .../src/hooks/admin/order-edits/mutations.ts | 28 ++- .../hooks/admin/order-edits/mutations.test.ts | 27 +++ .../__tests__/cancel-order-edit.ts | 34 +-- .../__tests__/confirm-order-edit.ts | 52 +++++ .../__tests__/request-confirmation.ts | 2 +- .../admin/order-edits/cancel-order-edit.ts | 12 +- .../admin/order-edits/confirm-order-edit.ts | 80 +++++++ .../src/api/routes/admin/order-edits/index.ts | 5 + .../admin/order-edits/request-confirmation.ts | 2 +- .../__tests__/decline-order-edit.ts | 2 +- .../store/order-edits/decline-order-edit.ts | 2 +- packages/medusa/src/models/line-item.ts | 2 +- .../src/services/__mocks__/order-edit.js | 54 +++-- .../src/services/__tests__/line-item.js | 95 ++++---- .../src/services/__tests__/order-edit.ts | 45 +++- packages/medusa/src/services/cart.ts | 21 +- packages/medusa/src/services/line-item.ts | 43 +++- packages/medusa/src/services/order-edit.ts | 188 ++++++++++------ packages/medusa/src/types/order-edit.ts | 4 - 22 files changed, 736 insertions(+), 198 deletions(-) create mode 100644 packages/medusa/src/api/routes/admin/order-edits/__tests__/confirm-order-edit.ts create mode 100644 packages/medusa/src/api/routes/admin/order-edits/confirm-order-edit.ts 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