From f7177c90332d7d77ca280158b56666c449889113 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Fri, 16 Sep 2022 10:29:40 +0200 Subject: [PATCH] feat(medusa): Implement premises of the creation flow of an order edit (#2187) **What** - Implements the admin create end point - Service implementation of the create method and the retrieveActive as well as the totals computation - Improve compute line items - client - medusa-js api - medusa-react mutations hooks **Tests** - Unit tests of the create end points - Unit tests of the service create method - Integration tests for admin that also take into account totals computations - client - medusa-js tests - medusa-react hooks tests FIXES CORE-491 --- .../api/__tests__/admin/order-edit.js | 135 ++++++++++- .../api/__tests__/store/order-edit.js | 13 +- .../src/resources/admin/order-edits.ts | 15 +- packages/medusa-react/mocks/handlers/admin.ts | 12 + .../src/hooks/admin/order-edits/mutations.ts | 20 +- .../src/hooks/admin/order-edits/queries.ts | 4 +- .../hooks/admin/order-edits/mutations.test.ts | 35 ++- .../__tests__/create-order-edit.ts | 48 ++++ .../admin/order-edits/create-order-edit.ts | 96 ++++++++ .../admin/order-edits/get-order-edit.ts | 10 + .../src/api/routes/admin/order-edits/index.ts | 19 +- .../store/order-edits/get-order-edit.ts | 10 + packages/medusa/src/models/order-edit.ts | 24 +- .../src/services/__mocks__/line-item.js | 14 +- .../src/services/__mocks__/order-edit.js | 33 ++- .../src/services/__tests__/order-edit.ts | 67 +++++- packages/medusa/src/services/order-edit.ts | 227 ++++++++++++++---- packages/medusa/src/services/order.ts | 47 ++-- packages/medusa/src/services/totals.ts | 26 +- packages/medusa/src/types/order-edit.ts | 5 + 20 files changed, 745 insertions(+), 115 deletions(-) create mode 100644 packages/medusa/src/api/routes/admin/order-edits/__tests__/create-order-edit.ts create mode 100644 packages/medusa/src/api/routes/admin/order-edits/create-order-edit.ts diff --git a/integration-tests/api/__tests__/admin/order-edit.js b/integration-tests/api/__tests__/admin/order-edit.js index 83ea4804fe..7b140278b3 100644 --- a/integration-tests/api/__tests__/admin/order-edit.js +++ b/integration-tests/api/__tests__/admin/order-edit.js @@ -115,14 +115,16 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => { await simpleLineItemFactory(dbConnection, { id: lineItemUpdateId, - order_id: orderEdit.order_id, + order_id: null, variant_id: product1.variants[0].id, + unit_price: 1000, quantity: 2, }) await simpleLineItemFactory(dbConnection, { id: lineItemCreateId, - order_id: orderEdit.order_id, + order_id: null, variant_id: product3.variants[0].id, + unit_price: 100, quantity: 2, }) @@ -175,6 +177,13 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => { removed_items: expect.arrayContaining([ expect.objectContaining({ id: lineItemId2, quantity: 1 }), ]), + shipping_total: 0, + gift_card_total: 0, + gift_card_tax_total: 0, + discount_total: 0, + tax_total: 0, + total: 2200, + subtotal: 2200, }) ) expect(response.status).toEqual(200) @@ -292,4 +301,126 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => { }) }) }) + + describe("POST /admin/order-edits", () => { + let orderId + const prodId1 = IdMap.getId("prodId1") + const prodId2 = IdMap.getId("prodId2") + const lineItemId1 = IdMap.getId("line-item-1") + const lineItemId2 = IdMap.getId("line-item-2") + + beforeEach(async () => { + await adminSeeder(dbConnection) + + const product1 = await simpleProductFactory(dbConnection, { + id: prodId1, + }) + const product2 = await simpleProductFactory(dbConnection, { + id: prodId2, + }) + + const order = await simpleOrderFactory(dbConnection, { + email: "test@testson.com", + tax_rate: null, + fulfillment_status: "fulfilled", + payment_status: "captured", + region: { + id: "test-region", + name: "Test region", + tax_rate: 12.5, + }, + line_items: [ + { + id: lineItemId1, + variant_id: product1.variants[0].id, + quantity: 1, + fulfilled_quantity: 1, + shipped_quantity: 1, + unit_price: 1000, + }, + { + id: lineItemId2, + variant_id: product2.variants[0].id, + quantity: 1, + fulfilled_quantity: 1, + shipped_quantity: 1, + unit_price: 1000, + }, + ], + }) + orderId = order.id + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("creates and order edit", async () => { + const api = useApi() + + const response = await api.post( + `/admin/order-edits/`, + { + order_id: orderId, + internal_note: "This is an internal note", + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.order_edit).toEqual( + expect.objectContaining({ + order_id: orderId, + created_by: "admin_user", + requested_by: null, + canceled_by: null, + confirmed_by: null, + internal_note: "This is an internal note", + items: expect.arrayContaining([ + expect.objectContaining({ + id: lineItemId1, + quantity: 1, + fulfilled_quantity: 1, + shipped_quantity: 1, + unit_price: 1000, + }), + expect.objectContaining({ + id: lineItemId2, + quantity: 1, + fulfilled_quantity: 1, + shipped_quantity: 1, + unit_price: 1000, + }), + ]), + shipping_total: 0, + gift_card_total: 0, + gift_card_tax_total: 0, + discount_total: 0, + tax_total: 0, + total: 2000, + subtotal: 2000, + }) + ) + }) + + it("throw an error if an active order edit already exists", async () => { + const api = useApi() + + const payload = { + order_id: orderId, + internal_note: "This is an internal note", + } + + await api.post(`/admin/order-edits/`, payload, adminHeaders) + const err = await api + .post(`/admin/order-edits/`, payload, adminHeaders) + .catch((e) => e) + + expect(err.message).toBe("Request failed with status code 400") + expect(err.response.data.message).toBe( + `An active order edit already exists for the order ${payload.order_id}` + ) + }) + }) }) diff --git a/integration-tests/api/__tests__/store/order-edit.js b/integration-tests/api/__tests__/store/order-edit.js index 6f8a43e4a3..4b50f7d591 100644 --- a/integration-tests/api/__tests__/store/order-edit.js +++ b/integration-tests/api/__tests__/store/order-edit.js @@ -114,14 +114,16 @@ describe("[MEDUSA_FF_ORDER_EDITING] /store/order-edits", () => { await simpleLineItemFactory(dbConnection, { id: lineItemUpdateId, - order_id: orderEdit.order_id, + order_id: null, variant_id: product1.variants[0].id, + unit_price: 1000, quantity: 2, }) await simpleLineItemFactory(dbConnection, { id: lineItemCreateId, - order_id: orderEdit.order_id, + order_id: null, variant_id: product3.variants[0].id, + unit_price: 100, quantity: 2, }) @@ -168,6 +170,13 @@ describe("[MEDUSA_FF_ORDER_EDITING] /store/order-edits", () => { removed_items: expect.arrayContaining([ expect.objectContaining({ id: lineItemId2, quantity: 1 }), ]), + shipping_total: 0, + gift_card_total: 0, + gift_card_tax_total: 0, + discount_total: 0, + tax_total: 0, + total: 2200, + subtotal: 2200, }) ) diff --git a/packages/medusa-js/src/resources/admin/order-edits.ts b/packages/medusa-js/src/resources/admin/order-edits.ts index 8e81fa5aa0..111fec7b8a 100644 --- a/packages/medusa-js/src/resources/admin/order-edits.ts +++ b/packages/medusa-js/src/resources/admin/order-edits.ts @@ -1,6 +1,7 @@ import { - AdminOrdersEditsRes, - AdminOrderEditDeleteRes + AdminOrderEditDeleteRes, + AdminOrderEditsRes, + AdminPostOrderEditsReq, } from "@medusajs/medusa" import { ResponsePromise } from "../../typings" import BaseResource from "../base" @@ -9,11 +10,19 @@ class AdminOrderEditsResource extends BaseResource { retrieve( id: string, customHeaders: Record = {} - ): ResponsePromise { + ): ResponsePromise { const path = `/admin/order-edits/${id}` return this.client.request("GET", path, undefined, {}, customHeaders) } + create( + payload: AdminPostOrderEditsReq, + customHeaders: Record = {} + ): ResponsePromise { + const path = `/admin/order-edits` + return this.client.request("POST", path, payload, {}, customHeaders) + } + delete( id: string, customHeaders: Record = {} diff --git a/packages/medusa-react/mocks/handlers/admin.ts b/packages/medusa-react/mocks/handlers/admin.ts index 97755a2bb9..b6068f946e 100644 --- a/packages/medusa-react/mocks/handlers/admin.ts +++ b/packages/medusa-react/mocks/handlers/admin.ts @@ -1664,6 +1664,18 @@ export const adminHandlers = [ ) }), + rest.post("/admin/order-edits/", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + order_edit: { + ...fixtures.get("order_edit"), + ...(req.body as any), + }, + }) + ) + }), + rest.get("/store/order-edits/:id", (req, res, ctx) => { const { id } = req.params return res( 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 701499309f..7214771dd9 100644 --- a/packages/medusa-react/src/hooks/admin/order-edits/mutations.ts +++ b/packages/medusa-react/src/hooks/admin/order-edits/mutations.ts @@ -1,11 +1,29 @@ import { AdminOrderEditDeleteRes, + AdminOrderEditsRes, + AdminPostOrderEditsReq, } from "@medusajs/medusa" import { Response } from "@medusajs/medusa-js" import { useMutation, UseMutationOptions, useQueryClient } from "react-query" import { adminOrderEditsKeys } from "." -import { useMedusa } from "../../../contexts/medusa" import { buildOptions } from "../../utils/buildOptions" +import { useMedusa } from "../../../contexts" + +export const useAdminCreateOrderEdit = ( + options?: UseMutationOptions< + Response, + Error, + AdminPostOrderEditsReq + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + return useMutation( + (payload: AdminPostOrderEditsReq) => + client.admin.orderEdits.create(payload), + buildOptions(queryClient, adminOrderEditsKeys.lists(), options) + ) +} export const useAdminDeleteOrderEdit = ( id: string, diff --git a/packages/medusa-react/src/hooks/admin/order-edits/queries.ts b/packages/medusa-react/src/hooks/admin/order-edits/queries.ts index 2960c3c324..be54711f25 100644 --- a/packages/medusa-react/src/hooks/admin/order-edits/queries.ts +++ b/packages/medusa-react/src/hooks/admin/order-edits/queries.ts @@ -1,4 +1,4 @@ -import { AdminOrdersEditsRes } from "@medusajs/medusa" +import { AdminOrderEditsRes } from "@medusajs/medusa" import { queryKeysFactory } from "../../utils" import { UseQueryOptionsWrapper } from "../../../types" import { Response } from "@medusajs/medusa-js" @@ -13,7 +13,7 @@ type OrderEditQueryKeys = typeof adminOrderEditsKeys export const useAdminOrderEdit = ( id: string, options?: UseQueryOptionsWrapper< - Response, + Response, Error, ReturnType > 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 ba00435c85..ea16eb2341 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,7 +1,10 @@ -import { useAdminDeleteOrderEdit } from "../../../../src/" +import { + useAdminCreateOrderEdit, + useAdminDeleteOrderEdit, +} from "../../../../src/" import { renderHook } from "@testing-library/react-hooks" -import { fixtures } from "../../../../mocks/data" import { createWrapper } from "../../../utils" +import { fixtures } from "../../../../mocks/data" describe("useAdminDelete hook", () => { test("Deletes an order edit", async () => { @@ -11,7 +14,6 @@ describe("useAdminDelete hook", () => { }) result.current.mutate() - await waitFor(() => result.current.isSuccess) expect(result.current.data.response.status).toEqual(200) @@ -24,3 +26,30 @@ describe("useAdminDelete hook", () => { ) }) }) + +describe("useAdminCreateOrderEdit hook", () => { + test("Created an order edit", async () => { + const { result, waitFor } = renderHook(() => useAdminCreateOrderEdit(), { + wrapper: createWrapper(), + }) + + const payload = { + order_id: "ord_1", + internal_note: "This is an internal note", + } + + result.current.mutate(payload) + + 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"), + ...payload, + }, + }) + ) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/order-edits/__tests__/create-order-edit.ts b/packages/medusa/src/api/routes/admin/order-edits/__tests__/create-order-edit.ts new file mode 100644 index 0000000000..1fc10e5c82 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/order-edits/__tests__/create-order-edit.ts @@ -0,0 +1,48 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { orderEditServiceMock } from "../../../../../services/__mocks__/order-edit" +import OrderEditingFeatureFlag from "../../../../../loaders/feature-flags/order-editing" + +describe("POST /admin/order-edits", () => { + describe("successfully create an order edit", () => { + const orderId = IdMap.getId("order-edit-order-id-test") + const internalNote = "test internal note" + let subject + + beforeAll(async () => { + subject = await request("POST", "/admin/order-edits", { + payload: { + order_id: orderId, + internal_note: internalNote, + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + flags: [OrderEditingFeatureFlag], + }) + }) + + afterAll(async () => { + jest.clearAllMocks() + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls order edit service create", () => { + expect(orderEditServiceMock.create).toHaveBeenCalledTimes(1) + expect(orderEditServiceMock.create).toHaveBeenCalledWith( + { + order_id: orderId, + internal_note: internalNote, + }, + { + loggedInUserId: IdMap.getId("admin_user"), + } + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/order-edits/create-order-edit.ts b/packages/medusa/src/api/routes/admin/order-edits/create-order-edit.ts new file mode 100644 index 0000000000..88cb38ce47 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/order-edits/create-order-edit.ts @@ -0,0 +1,96 @@ +import { Request, Response } from "express" +import { OrderEditService } from "../../../../services" +import { IsOptional, IsString } from "class-validator" +import { EntityManager } from "typeorm" + +/** + * @oas [post] /order-edits + * operationId: "PostOrderEdits" + * summary: "Create an OrderEdit" + * description: "Created a OrderEdit." + * x-authenticated: true + * 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.create({ order_id, internal_note }) + * .then(({ order_edit }) => { + * console.log(order_edit.id); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request POST 'https://medusa-url.com/admin/order-edits' \ + * --header 'Authorization: Bearer {api_token}' + * -d '{ "order_id": "my_order_id", "internal_note": "my_optional_note" }' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - OrderEdit + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * order_edit: + * $ref: "#/components/schemas/order_edit" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response) => { + const orderEditService = req.scope.resolve( + "orderEditService" + ) as OrderEditService + const manager = req.scope.resolve("manager") as EntityManager + + const data = req.validatedBody as AdminPostOrderEditsReq + const loggedInUserId = (req.user?.id ?? req.user?.userId) as string + + const orderEdit = await manager.transaction(async (transactionManager) => { + const orderEditServiceTx = + orderEditService.withTransaction(transactionManager) + const orderEdit = await orderEditServiceTx.create(data, { loggedInUserId }) + + const { items } = await orderEditServiceTx.computeLineItems(orderEdit.id) + orderEdit.items = items + orderEdit.removed_items = [] + + const totals = await orderEditServiceTx.getTotals(orderEdit.id) + orderEdit.discount_total = totals.discount_total + orderEdit.gift_card_total = totals.gift_card_total + orderEdit.gift_card_tax_total = totals.gift_card_tax_total + orderEdit.shipping_total = totals.shipping_total + orderEdit.subtotal = totals.subtotal + orderEdit.tax_total = totals.tax_total + orderEdit.total = totals.total + + return orderEdit + }) + + return res.json({ order_edit: orderEdit }) +} + +export class AdminPostOrderEditsReq { + @IsString() + order_id: string + + @IsOptional() + @IsString() + internal_note?: string +} diff --git a/packages/medusa/src/api/routes/admin/order-edits/get-order-edit.ts b/packages/medusa/src/api/routes/admin/order-edits/get-order-edit.ts index e0f981d6a7..356a524de9 100644 --- a/packages/medusa/src/api/routes/admin/order-edits/get-order-edit.ts +++ b/packages/medusa/src/api/routes/admin/order-edits/get-order-edit.ts @@ -60,9 +60,19 @@ export default async (req: Request, res: Response) => { const retrieveConfig = req.retrieveConfig const orderEdit = await orderEditService.retrieve(id, retrieveConfig) + const { items, removedItems } = await orderEditService.computeLineItems(id) orderEdit.items = items orderEdit.removed_items = removedItems + const totals = await orderEditService.getTotals(orderEdit.id) + orderEdit.discount_total = totals.discount_total + orderEdit.gift_card_total = totals.gift_card_total + orderEdit.gift_card_tax_total = totals.gift_card_tax_total + orderEdit.shipping_total = totals.shipping_total + orderEdit.subtotal = totals.subtotal + orderEdit.tax_total = totals.tax_total + orderEdit.total = totals.total + 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 81d618bfd8..02641a4b84 100644 --- a/packages/medusa/src/api/routes/admin/order-edits/index.ts +++ b/packages/medusa/src/api/routes/admin/order-edits/index.ts @@ -1,6 +1,9 @@ import { Router } from "express" -import middlewares, { transformQuery } from "../../../middlewares" -import { EmptyQueryParams } from "../../../../types/common" +import middlewares, { + transformBody, + transformQuery, +} from "../../../middlewares" +import { DeleteResponse, EmptyQueryParams } from "../../../../types/common" import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled" import OrderEditingFeatureFlag from "../../../../loaders/feature-flags/order-editing" import { @@ -8,7 +11,7 @@ import { defaultOrderEditRelations, } from "../../../../types/order-edit" import { OrderEdit } from "../../../../models" -import { DeleteResponse } from "../../../../types/common" +import { AdminPostOrderEditsReq } from "./create-order-edit" const route = Router() @@ -19,6 +22,12 @@ export default (app) => { route ) + route.post( + "/", + transformBody(AdminPostOrderEditsReq), + middlewares.wrap(require("./create-order-edit").default) + ) + route.get( "/:id", transformQuery(EmptyQueryParams, { @@ -34,7 +43,9 @@ export default (app) => { return app } -export type AdminOrdersEditsRes = { +export type AdminOrderEditsRes = { order_edit: OrderEdit } export type AdminOrderEditDeleteRes = DeleteResponse + +export * from "./create-order-edit" diff --git a/packages/medusa/src/api/routes/store/order-edits/get-order-edit.ts b/packages/medusa/src/api/routes/store/order-edits/get-order-edit.ts index 4f4621d261..a70f13d064 100644 --- a/packages/medusa/src/api/routes/store/order-edits/get-order-edit.ts +++ b/packages/medusa/src/api/routes/store/order-edits/get-order-edit.ts @@ -54,9 +54,19 @@ export default async (req: Request, res: Response) => { const retrieveConfig = req.retrieveConfig const orderEdit = await orderEditService.retrieve(id, retrieveConfig) + const { items, removedItems } = await orderEditService.computeLineItems(id) orderEdit.items = items orderEdit.removed_items = removedItems + const totals = await orderEditService.getTotals(orderEdit.id) + orderEdit.discount_total = totals.discount_total + orderEdit.gift_card_total = totals.gift_card_total + orderEdit.gift_card_tax_total = totals.gift_card_tax_total + orderEdit.shipping_total = totals.shipping_total + orderEdit.subtotal = totals.subtotal + orderEdit.tax_total = totals.tax_total + orderEdit.total = totals.total + return res.json({ order_edit: orderEdit }) } diff --git a/packages/medusa/src/models/order-edit.ts b/packages/medusa/src/models/order-edit.ts index e207a3609d..30aad3c544 100644 --- a/packages/medusa/src/models/order-edit.ts +++ b/packages/medusa/src/models/order-edit.ts @@ -73,10 +73,14 @@ export class OrderEdit extends SoftDeletableEntity { canceled_at?: Date // Computed - subtotal: number - discount_total?: number - tax_total: number + shipping_total: number + discount_total: number + tax_total: number | null total: number + subtotal: number + gift_card_total: number + gift_card_tax_total: number + difference_due: number status: OrderEditStatus @@ -168,12 +172,24 @@ export class OrderEdit extends SoftDeletableEntity { * type: string * subtotal: * type: integer - * description: The subtotal for line items computed from changes. + * description: The total of subtotal * example: 8000 * discount_total: * type: integer * description: The total of discount * example: 800 + * shipping_total: + * type: integer + * description: The total of the shipping amount + * example: 800 + * gift_card_total: + * type: integer + * description: The total of the gift card amount + * example: 800 + * gift_card_tax_total: + * type: integer + * description: The total of the gift card tax amount + * example: 800 * tax_total: * type: integer * description: The total of tax diff --git a/packages/medusa/src/services/__mocks__/line-item.js b/packages/medusa/src/services/__mocks__/line-item.js index ff86faee56..1a0a96a515 100644 --- a/packages/medusa/src/services/__mocks__/line-item.js +++ b/packages/medusa/src/services/__mocks__/line-item.js @@ -2,16 +2,22 @@ import { IdMap } from "medusa-test-utils" import { MedusaError } from "medusa-core-utils" export const LineItemServiceMock = { - withTransaction: function() { + withTransaction: function () { return this }, - create: jest.fn().mockImplementation(data => { + list: jest.fn().mockImplementation((data) => { + return Promise.resolve([]) + }), + retrieve: jest.fn().mockImplementation((data) => { + return Promise.resolve({}) + }), + create: jest.fn().mockImplementation((data) => { return Promise.resolve({ ...data }) }), - update: jest.fn().mockImplementation(data => { + update: jest.fn().mockImplementation((data) => { return Promise.resolve({ ...data }) }), - validate: jest.fn().mockImplementation(data => { + validate: jest.fn().mockImplementation((data) => { if (data.title === "invalid lineitem") { throw new Error(`"content" is required`) } diff --git a/packages/medusa/src/services/__mocks__/order-edit.js b/packages/medusa/src/services/__mocks__/order-edit.js index 5c6e20933c..a2645f4aca 100644 --- a/packages/medusa/src/services/__mocks__/order-edit.js +++ b/packages/medusa/src/services/__mocks__/order-edit.js @@ -30,7 +30,38 @@ export const orderEditServiceMock = { return Promise.resolve(undefined) }), computeLineItems: jest.fn().mockImplementation((orderEdit) => { - return Promise.resolve(orderEdit) + return Promise.resolve({ + items: [ + { + id: IdMap.getId("existingLine"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 123, + variant: { + id: IdMap.getId("can-cover"), + }, + product: { + id: IdMap.getId("validId"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + removedItems: [], + }) + }), + create: jest.fn().mockImplementation((data, context) => { + return Promise.resolve({ + order_id: data.order_id, + internal_note: data.internal_note, + created_by: context.loggedInUserId, + }) + }), + getTotals: jest.fn().mockImplementation(() => { + return Promise.resolve({}) }), delete: jest.fn().mockImplementation((_) => { return Promise.resolve() diff --git a/packages/medusa/src/services/__tests__/order-edit.ts b/packages/medusa/src/services/__tests__/order-edit.ts index a4ee3174d3..85bf7ac717 100644 --- a/packages/medusa/src/services/__tests__/order-edit.ts +++ b/packages/medusa/src/services/__tests__/order-edit.ts @@ -1,7 +1,16 @@ import { IdMap, MockManager, MockRepository } from "medusa-test-utils" -import { OrderEditService, OrderService } from "../index" +import { + EventBusService, + LineItemService, + OrderEditService, + OrderService, + TotalsService, +} from "../index" import { OrderEditItemChangeType } from "../../models" import { OrderServiceMock } from "../__mocks__/order" +import { EventBusServiceMock } from "../__mocks__/event-bus" +import { LineItemServiceMock } from "../__mocks__/line-item" +import { TotalsServiceMock } from "../__mocks__/totals" const orderEditWithChanges = { id: IdMap.getId("order-edit-with-changes"), @@ -48,6 +57,25 @@ const orderEditWithChanges = { ], } +const lineItemServiceMock = { + ...LineItemServiceMock, + list: jest.fn().mockImplementation(() => { + return Promise.resolve([ + { + id: IdMap.getId("line-item-1"), + }, + { + id: IdMap.getId("line-item-2"), + }, + ]) + }), + retrieve: jest.fn().mockImplementation((id) => { + return Promise.resolve({ + id, + }) + }), +} + describe("OrderEditService", () => { const orderEditRepository = MockRepository({ findOneWithRelations: (relations, query) => { @@ -57,12 +85,21 @@ describe("OrderEditService", () => { return {} }, + create: (data) => { + return { + ...orderEditWithChanges, + ...data, + } + }, }) const orderEditService = new OrderEditService({ manager: MockManager, orderEditRepository, orderService: OrderServiceMock as unknown as OrderService, + eventBusService: EventBusServiceMock as unknown as EventBusService, + totalsService: TotalsServiceMock as unknown as TotalsService, + lineItemService: lineItemServiceMock as unknown as LineItemService, }) it("should retrieve an order edit and call the repository with the right arguments", async () => { @@ -77,12 +114,10 @@ describe("OrderEditService", () => { }) it("should compute the items from the changes and attach them to the orderEdit", async () => { - const orderEdit = await orderEditService.retrieve( + const { items, removedItems } = await orderEditService.computeLineItems( IdMap.getId("order-edit-with-changes") ) - const { items, removedItems } = await orderEditService.computeLineItems( - orderEdit.id - ) + expect(items.length).toBe(2) expect(items).toEqual( expect.arrayContaining([ @@ -104,4 +139,26 @@ describe("OrderEditService", () => { ]) ) }) + + it("should create an order edit and call the repository with the right arguments as well as the event bus service", async () => { + const data = { + order_id: IdMap.getId("order-edit-order-id"), + internal_note: "internal note", + } + await orderEditService.create(data, { + loggedInUserId: IdMap.getId("admin_user"), + }) + + expect(orderEditRepository.create).toHaveBeenCalledTimes(1) + expect(orderEditRepository.create).toHaveBeenCalledWith({ + order_id: data.order_id, + internal_note: data.internal_note, + created_by: IdMap.getId("admin_user"), + }) + expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1) + expect(EventBusServiceMock.emit).toHaveBeenCalledWith( + OrderEditService.Events.CREATED, + { id: expect.any(String) } + ) + }) }) diff --git a/packages/medusa/src/services/order-edit.ts b/packages/medusa/src/services/order-edit.ts index 41817c11c1..398d238915 100644 --- a/packages/medusa/src/services/order-edit.ts +++ b/packages/medusa/src/services/order-edit.ts @@ -5,30 +5,49 @@ import { MedusaError } from "medusa-core-utils" import { OrderEditRepository } from "../repositories/order-edit" import { LineItem, + Order, OrderEdit, OrderEditItemChangeType, OrderEditStatus, - OrderItemChange, } from "../models" import { TransactionBaseService } from "../interfaces" -import { OrderService } from "./index" +import { + EventBusService, + LineItemService, + OrderService, + TotalsService, +} from "./index" +import { CreateOrderEditInput } from "../types/order-edit" type InjectedDependencies = { manager: EntityManager orderEditRepository: typeof OrderEditRepository orderService: OrderService + eventBusService: EventBusService + totalsService: TotalsService + lineItemService: LineItemService } export default class OrderEditService extends TransactionBaseService { + static readonly Events = { + CREATED: "order-edit.created", + } + protected transactionManager_: EntityManager | undefined protected readonly manager_: EntityManager protected readonly orderEditRepository_: typeof OrderEditRepository protected readonly orderService_: OrderService + protected readonly lineItemService_: LineItemService + protected readonly eventBusService_: EventBusService + protected readonly totalsService_: TotalsService constructor({ manager, orderEditRepository, orderService, + lineItemService, + eventBusService, + totalsService, }: InjectedDependencies) { // eslint-disable-next-line prefer-rest-params super(arguments[0]) @@ -36,13 +55,17 @@ export default class OrderEditService extends TransactionBaseService { this.manager_ = manager this.orderEditRepository_ = orderEditRepository this.orderService_ = orderService + this.lineItemService_ = lineItemService + this.eventBusService_ = eventBusService + this.totalsService_ = totalsService } async retrieve( orderEditId: string, config: FindConfig = {} ): Promise { - const orderEditRepository = this.manager_.getCustomRepository( + const manager = this.transactionManager_ ?? this.manager_ + const orderEditRepository = manager.getCustomRepository( this.orderEditRepository_ ) const { relations, ...query } = buildQuery({ id: orderEditId }, config) @@ -62,58 +85,178 @@ 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 }, config) + return await orderEditRepository.findOne(query) + } + + /** + * Compute line items across order and order edit + * - if an item have been removed, it will appear in the removedItems collection and will not appear in the item collection + * - if an item have been updated, it will appear in the item collection with id being the id of the original item and the rest of the data being the data of the new item generated from the update + * - if an item have been added, it will appear in the item collection with id being the id of the new item and the rest of the data being the data of the new item generated from the add + * @param orderEditId + */ async computeLineItems( orderEditId: string ): Promise<{ items: LineItem[]; removedItems: LineItem[] }> { + const manager = this.transactionManager_ ?? this.manager_ + + const lineItemServiceTx = this.lineItemService_.withTransaction(manager) + const orderEdit = await this.retrieve(orderEditId, { - select: ["id", "order_id", "changes", "order"], - relations: [ - "changes", - "changes.line_item", - "changes.original_line_item", - "order", - "order.items", - ], + select: ["id", "order_id", "changes"], + relations: ["changes", "changes.original_line_item", "changes.line_item"], }) - const originalItems = orderEdit.order.items - const removedItems: LineItem[] = [] const items: LineItem[] = [] + const orderEditRemovedItemsMap: Map = new Map() + const orderEditUpdatedItemsMap: Map = new Map() - const updatedItems = orderEdit.changes - .map((itemChange) => { - if (itemChange.type === OrderEditItemChangeType.ITEM_ADD) { - items.push(itemChange.line_item as LineItem) - return - } + for (const change of orderEdit.changes) { + const lineItemId = + change.type === OrderEditItemChangeType.ITEM_REMOVE + ? change.original_line_item_id! + : change.line_item_id! - if (itemChange.type === OrderEditItemChangeType.ITEM_REMOVE) { - removedItems.push({ - ...itemChange.original_line_item, - id: itemChange.original_line_item_id, - } as LineItem) - return - } - - return [itemChange.original_line_item_id as string, itemChange] + const lineItem = await lineItemServiceTx.retrieve(lineItemId!, { + relations: ["tax_lines", "adjustments"], }) - .filter((change) => !!change) as [string, OrderItemChange][] - const orderEditUpdatedChangesMap: Map = new Map( - updatedItems - ) - - originalItems.map((item) => { - const itemChange = orderEditUpdatedChangesMap.get(item.id) - if (itemChange) { - items.push({ - ...itemChange.line_item, - id: itemChange.original_line_item_id, - } as LineItem) + if (change.type === OrderEditItemChangeType.ITEM_REMOVE) { + orderEditRemovedItemsMap.set(change.original_line_item_id!, lineItem) + continue } - }) - return { items, removedItems } + if (change.type === OrderEditItemChangeType.ITEM_ADD) { + items.push(lineItem) + continue + } + + orderEditUpdatedItemsMap.set(change.original_line_item_id!, { + ...lineItem, + id: change.original_line_item_id!, + } as LineItem) + } + + const originalLineItems = await this.lineItemService_ + .withTransaction(manager) + .list( + { + order_id: orderEdit.order_id, + }, + { + relations: ["tax_lines", "adjustments"], + } + ) + + for (const originalLineItem of originalLineItems) { + const itemRemoved = orderEditRemovedItemsMap.get(originalLineItem.id) + if (itemRemoved) { + continue + } + + const updatedLineItem = orderEditUpdatedItemsMap.get(originalLineItem.id) + const lineItem = updatedLineItem ?? originalLineItem + items.push(lineItem) + } + + return { items, removedItems: [...orderEditRemovedItemsMap.values()] } + } + + /** + * Compute and return the different totals from the order edit id + * @param orderEditId + */ + async getTotals(orderEditId: string): Promise<{ + shipping_total: number + gift_card_total: number + gift_card_tax_total: number + discount_total: number + tax_total: number | null + subtotal: number + total: number + }> { + const manager = this.transactionManager_ ?? this.manager_ + const { order_id } = await this.retrieve(orderEditId, { + select: ["order_id"], + }) + const order = await this.orderService_ + .withTransaction(manager) + .retrieve(order_id, { + relations: [ + "discounts", + "discounts.rule", + "gift_cards", + "region", + "region.tax_rates", + "shipping_methods", + "shipping_methods.tax_lines", + ], + }) + const { items } = await this.computeLineItems(orderEditId) + const computedOrder = { ...order, items } as Order + + const totalsServiceTx = this.totalsService_.withTransaction(manager) + + const shipping_total = await totalsServiceTx.getShippingTotal(computedOrder) + const { total: gift_card_total, tax_total: gift_card_tax_total } = + await totalsServiceTx.getGiftCardTotal(computedOrder) + const discount_total = await totalsServiceTx.getDiscountTotal(computedOrder) + const tax_total = await totalsServiceTx.getTaxTotal(computedOrder) + const subtotal = await totalsServiceTx.getSubtotal(computedOrder) + const total = await totalsServiceTx.getTotal(computedOrder) + + return { + shipping_total, + gift_card_total, + gift_card_tax_total, + discount_total, + tax_total, + subtotal, + total, + } + } + + async create( + data: CreateOrderEditInput, + context: { loggedInUserId: string } + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const activeOrderEdit = await this.retrieveActive(data.order_id) + if (activeOrderEdit) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `An active order edit already exists for the order ${data.order_id}` + ) + } + + const orderEditRepository = transactionManager.getCustomRepository( + this.orderEditRepository_ + ) + + const orderEditToCreate = orderEditRepository.create({ + order_id: data.order_id, + internal_note: data.internal_note, + created_by: context.loggedInUserId, + }) + + const orderEdit = await orderEditRepository.save(orderEditToCreate) + + await this.eventBusService_ + .withTransaction(transactionManager) + .emit(OrderEditService.Events.CREATED, { id: orderEdit.id }) + + return orderEdit + }) } async delete(orderEditId: string): Promise { diff --git a/packages/medusa/src/services/order.ts b/packages/medusa/src/services/order.ts index ba43df641f..c3a5f2cf51 100644 --- a/packages/medusa/src/services/order.ts +++ b/packages/medusa/src/services/order.ts @@ -11,7 +11,6 @@ import { Order, OrderStatus, Payment, - PaymentSession, PaymentStatus, Return, Swap, @@ -164,9 +163,8 @@ class OrderService extends TransactionBaseService { const orderRepo = this.manager_.getCustomRepository(this.orderRepository_) const query = buildQuery(selector, config) - const { select, relations, totalsToSelect } = this.transformQueryForTotals( - config - ) + const { select, relations, totalsToSelect } = + this.transformQueryForTotals(config) if (select && select.length) { query.select = select @@ -234,9 +232,8 @@ class OrderService extends TransactionBaseService { } } - const { select, relations, totalsToSelect } = this.transformQueryForTotals( - config - ) + const { select, relations, totalsToSelect } = + this.transformQueryForTotals(config) if (select && select.length) { query.select = select @@ -254,9 +251,7 @@ class OrderService extends TransactionBaseService { return [orders, count] } - protected transformQueryForTotals( - config: FindConfig - ): { + protected transformQueryForTotals(config: FindConfig): { relations: string[] | undefined select: FindConfig["select"] totalsToSelect: FindConfig["select"] @@ -337,9 +332,8 @@ class OrderService extends TransactionBaseService { ): Promise { const orderRepo = this.manager_.getCustomRepository(this.orderRepository_) - const { select, relations, totalsToSelect } = this.transformQueryForTotals( - config - ) + const { select, relations, totalsToSelect } = + this.transformQueryForTotals(config) const query = { where: { id: orderId }, @@ -378,9 +372,8 @@ class OrderService extends TransactionBaseService { ): Promise { const orderRepo = this.manager_.getCustomRepository(this.orderRepository_) - const { select, relations, totalsToSelect } = this.transformQueryForTotals( - config - ) + const { select, relations, totalsToSelect } = + this.transformQueryForTotals(config) const query = { where: { cart_id: cartId }, @@ -418,9 +411,8 @@ class OrderService extends TransactionBaseService { ): Promise { const orderRepo = this.manager_.getCustomRepository(this.orderRepository_) - const { select, relations, totalsToSelect } = this.transformQueryForTotals( - config - ) + const { select, relations, totalsToSelect } = + this.transformQueryForTotals(config) const query = { where: { external_id: externalId }, @@ -858,9 +850,8 @@ class OrderService extends TransactionBaseService { .withTransaction(manager) .createShippingMethod(optionId, data ?? {}, { order, ...config }) - const shippingOptionServiceTx = this.shippingOptionService_.withTransaction( - manager - ) + const shippingOptionServiceTx = + this.shippingOptionService_.withTransaction(manager) const methods = [newMethod] if (shipping_methods.length) { @@ -1031,9 +1022,8 @@ class OrderService extends TransactionBaseService { await inventoryServiceTx.adjustInventory(item.variant_id, item.quantity) } - const paymentProviderServiceTx = this.paymentProviderService_.withTransaction( - manager - ) + const paymentProviderServiceTx = + this.paymentProviderService_.withTransaction(manager) for (const p of order.payments) { await paymentProviderServiceTx.cancelPayment(p) } @@ -1073,9 +1063,8 @@ class OrderService extends TransactionBaseService { ) } - const paymentProviderServiceTx = this.paymentProviderService_.withTransaction( - manager - ) + const paymentProviderServiceTx = + this.paymentProviderService_.withTransaction(manager) const payments: Payment[] = [] for (const p of order.payments) { @@ -1228,7 +1217,7 @@ class OrderService extends TransactionBaseService { const fulfillments = await this.fulfillmentService_ .withTransaction(manager) .createFulfillment( - (order as unknown) as CreateFulfillmentOrder, + order as unknown as CreateFulfillmentOrder, itemsToFulfill, { metadata, diff --git a/packages/medusa/src/services/totals.ts b/packages/medusa/src/services/totals.ts index 59dc48722d..dd15a47a1c 100644 --- a/packages/medusa/src/services/totals.ts +++ b/packages/medusa/src/services/totals.ts @@ -243,11 +243,12 @@ class TotalsService extends TransactionBaseService { TaxInclusivePricingFeatureFlag.key ) && shippingMethod.includes_tax - totals.original_tax_total = await this.taxCalculationStrategy_.calculate( - [], - totals.tax_lines, - calculationContext - ) + totals.original_tax_total = + await this.taxCalculationStrategy_.calculate( + [], + totals.tax_lines, + calculationContext + ) totals.tax_total = totals.original_tax_total if (includesTax) { @@ -904,11 +905,12 @@ class TotalsService extends TransactionBaseService { calculationContext ) calculationContext.allocation_map = {} // Don't account for discounts - lineItemTotals.original_tax_total = await this.taxCalculationStrategy_.calculate( - [lineItem], - lineItemTotals.tax_lines, - calculationContext - ) + lineItemTotals.original_tax_total = + await this.taxCalculationStrategy_.calculate( + [lineItem], + lineItemTotals.tax_lines, + calculationContext + ) if ( this.featureFlagRouter_.isFeatureEnabled( @@ -986,9 +988,7 @@ class TotalsService extends TransactionBaseService { * @param cartOrOrder - the cart or order to get gift card amount for * @return the gift card amount applied to the cart or order */ - async getGiftCardTotal( - cartOrOrder: Cart | Order - ): Promise<{ + async getGiftCardTotal(cartOrOrder: Cart | Order): Promise<{ total: number tax_total: number }> { diff --git a/packages/medusa/src/types/order-edit.ts b/packages/medusa/src/types/order-edit.ts index cfe0cead14..6b27926072 100644 --- a/packages/medusa/src/types/order-edit.ts +++ b/packages/medusa/src/types/order-edit.ts @@ -22,3 +22,8 @@ export const defaultOrderEditFields: (keyof OrderEdit)[] = [ "canceled_at", "internal_note", ] + +export type CreateOrderEditInput = { + order_id: string + internal_note?: string +}