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
This commit is contained in:
Adrien de Peretti
2022-09-16 08:29:40 +00:00
committed by GitHub
parent 6132711eef
commit f7177c9033
20 changed files with 745 additions and 115 deletions
@@ -115,14 +115,16 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => {
await simpleLineItemFactory(dbConnection, { await simpleLineItemFactory(dbConnection, {
id: lineItemUpdateId, id: lineItemUpdateId,
order_id: orderEdit.order_id, order_id: null,
variant_id: product1.variants[0].id, variant_id: product1.variants[0].id,
unit_price: 1000,
quantity: 2, quantity: 2,
}) })
await simpleLineItemFactory(dbConnection, { await simpleLineItemFactory(dbConnection, {
id: lineItemCreateId, id: lineItemCreateId,
order_id: orderEdit.order_id, order_id: null,
variant_id: product3.variants[0].id, variant_id: product3.variants[0].id,
unit_price: 100,
quantity: 2, quantity: 2,
}) })
@@ -175,6 +177,13 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => {
removed_items: expect.arrayContaining([ removed_items: expect.arrayContaining([
expect.objectContaining({ id: lineItemId2, quantity: 1 }), 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) 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}`
)
})
})
}) })
@@ -114,14 +114,16 @@ describe("[MEDUSA_FF_ORDER_EDITING] /store/order-edits", () => {
await simpleLineItemFactory(dbConnection, { await simpleLineItemFactory(dbConnection, {
id: lineItemUpdateId, id: lineItemUpdateId,
order_id: orderEdit.order_id, order_id: null,
variant_id: product1.variants[0].id, variant_id: product1.variants[0].id,
unit_price: 1000,
quantity: 2, quantity: 2,
}) })
await simpleLineItemFactory(dbConnection, { await simpleLineItemFactory(dbConnection, {
id: lineItemCreateId, id: lineItemCreateId,
order_id: orderEdit.order_id, order_id: null,
variant_id: product3.variants[0].id, variant_id: product3.variants[0].id,
unit_price: 100,
quantity: 2, quantity: 2,
}) })
@@ -168,6 +170,13 @@ describe("[MEDUSA_FF_ORDER_EDITING] /store/order-edits", () => {
removed_items: expect.arrayContaining([ removed_items: expect.arrayContaining([
expect.objectContaining({ id: lineItemId2, quantity: 1 }), 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,
}) })
) )
@@ -1,6 +1,7 @@
import { import {
AdminOrdersEditsRes, AdminOrderEditDeleteRes,
AdminOrderEditDeleteRes AdminOrderEditsRes,
AdminPostOrderEditsReq,
} from "@medusajs/medusa" } from "@medusajs/medusa"
import { ResponsePromise } from "../../typings" import { ResponsePromise } from "../../typings"
import BaseResource from "../base" import BaseResource from "../base"
@@ -9,11 +10,19 @@ class AdminOrderEditsResource extends BaseResource {
retrieve( retrieve(
id: string, id: string,
customHeaders: Record<string, any> = {} customHeaders: Record<string, any> = {}
): ResponsePromise<AdminOrdersEditsRes> { ): ResponsePromise<AdminOrderEditsRes> {
const path = `/admin/order-edits/${id}` const path = `/admin/order-edits/${id}`
return this.client.request("GET", path, undefined, {}, customHeaders) return this.client.request("GET", path, undefined, {}, customHeaders)
} }
create(
payload: AdminPostOrderEditsReq,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminOrderEditsRes> {
const path = `/admin/order-edits`
return this.client.request("POST", path, payload, {}, customHeaders)
}
delete( delete(
id: string, id: string,
customHeaders: Record<string, any> = {} customHeaders: Record<string, any> = {}
@@ -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) => { rest.get("/store/order-edits/:id", (req, res, ctx) => {
const { id } = req.params const { id } = req.params
return res( return res(
@@ -1,11 +1,29 @@
import { import {
AdminOrderEditDeleteRes, AdminOrderEditDeleteRes,
AdminOrderEditsRes,
AdminPostOrderEditsReq,
} from "@medusajs/medusa" } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js" import { Response } from "@medusajs/medusa-js"
import { useMutation, UseMutationOptions, useQueryClient } from "react-query" import { useMutation, UseMutationOptions, useQueryClient } from "react-query"
import { adminOrderEditsKeys } from "." import { adminOrderEditsKeys } from "."
import { useMedusa } from "../../../contexts/medusa"
import { buildOptions } from "../../utils/buildOptions" import { buildOptions } from "../../utils/buildOptions"
import { useMedusa } from "../../../contexts"
export const useAdminCreateOrderEdit = (
options?: UseMutationOptions<
Response<AdminOrderEditsRes>,
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 = ( export const useAdminDeleteOrderEdit = (
id: string, id: string,
@@ -1,4 +1,4 @@
import { AdminOrdersEditsRes } from "@medusajs/medusa" import { AdminOrderEditsRes } from "@medusajs/medusa"
import { queryKeysFactory } from "../../utils" import { queryKeysFactory } from "../../utils"
import { UseQueryOptionsWrapper } from "../../../types" import { UseQueryOptionsWrapper } from "../../../types"
import { Response } from "@medusajs/medusa-js" import { Response } from "@medusajs/medusa-js"
@@ -13,7 +13,7 @@ type OrderEditQueryKeys = typeof adminOrderEditsKeys
export const useAdminOrderEdit = ( export const useAdminOrderEdit = (
id: string, id: string,
options?: UseQueryOptionsWrapper< options?: UseQueryOptionsWrapper<
Response<AdminOrdersEditsRes>, Response<AdminOrderEditsRes>,
Error, Error,
ReturnType<OrderEditQueryKeys["detail"]> ReturnType<OrderEditQueryKeys["detail"]>
> >
@@ -1,7 +1,10 @@
import { useAdminDeleteOrderEdit } from "../../../../src/" import {
useAdminCreateOrderEdit,
useAdminDeleteOrderEdit,
} from "../../../../src/"
import { renderHook } from "@testing-library/react-hooks" import { renderHook } from "@testing-library/react-hooks"
import { fixtures } from "../../../../mocks/data"
import { createWrapper } from "../../../utils" import { createWrapper } from "../../../utils"
import { fixtures } from "../../../../mocks/data"
describe("useAdminDelete hook", () => { describe("useAdminDelete hook", () => {
test("Deletes an order edit", async () => { test("Deletes an order edit", async () => {
@@ -11,7 +14,6 @@ describe("useAdminDelete hook", () => {
}) })
result.current.mutate() result.current.mutate()
await waitFor(() => result.current.isSuccess) await waitFor(() => result.current.isSuccess)
expect(result.current.data.response.status).toEqual(200) 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,
},
})
)
})
})
@@ -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"),
}
)
})
})
})
@@ -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
}
@@ -60,9 +60,19 @@ export default async (req: Request, res: Response) => {
const retrieveConfig = req.retrieveConfig const retrieveConfig = req.retrieveConfig
const orderEdit = await orderEditService.retrieve(id, retrieveConfig) const orderEdit = await orderEditService.retrieve(id, retrieveConfig)
const { items, removedItems } = await orderEditService.computeLineItems(id) const { items, removedItems } = await orderEditService.computeLineItems(id)
orderEdit.items = items orderEdit.items = items
orderEdit.removed_items = removedItems 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 }) return res.json({ order_edit: orderEdit })
} }
@@ -1,6 +1,9 @@
import { Router } from "express" import { Router } from "express"
import middlewares, { transformQuery } from "../../../middlewares" import middlewares, {
import { EmptyQueryParams } from "../../../../types/common" transformBody,
transformQuery,
} from "../../../middlewares"
import { DeleteResponse, EmptyQueryParams } from "../../../../types/common"
import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled" import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled"
import OrderEditingFeatureFlag from "../../../../loaders/feature-flags/order-editing" import OrderEditingFeatureFlag from "../../../../loaders/feature-flags/order-editing"
import { import {
@@ -8,7 +11,7 @@ import {
defaultOrderEditRelations, defaultOrderEditRelations,
} from "../../../../types/order-edit" } from "../../../../types/order-edit"
import { OrderEdit } from "../../../../models" import { OrderEdit } from "../../../../models"
import { DeleteResponse } from "../../../../types/common" import { AdminPostOrderEditsReq } from "./create-order-edit"
const route = Router() const route = Router()
@@ -19,6 +22,12 @@ export default (app) => {
route route
) )
route.post(
"/",
transformBody(AdminPostOrderEditsReq),
middlewares.wrap(require("./create-order-edit").default)
)
route.get( route.get(
"/:id", "/:id",
transformQuery(EmptyQueryParams, { transformQuery(EmptyQueryParams, {
@@ -34,7 +43,9 @@ export default (app) => {
return app return app
} }
export type AdminOrdersEditsRes = { export type AdminOrderEditsRes = {
order_edit: OrderEdit order_edit: OrderEdit
} }
export type AdminOrderEditDeleteRes = DeleteResponse export type AdminOrderEditDeleteRes = DeleteResponse
export * from "./create-order-edit"
@@ -54,9 +54,19 @@ export default async (req: Request, res: Response) => {
const retrieveConfig = req.retrieveConfig const retrieveConfig = req.retrieveConfig
const orderEdit = await orderEditService.retrieve(id, retrieveConfig) const orderEdit = await orderEditService.retrieve(id, retrieveConfig)
const { items, removedItems } = await orderEditService.computeLineItems(id) const { items, removedItems } = await orderEditService.computeLineItems(id)
orderEdit.items = items orderEdit.items = items
orderEdit.removed_items = removedItems 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 }) return res.json({ order_edit: orderEdit })
} }
+20 -4
View File
@@ -73,10 +73,14 @@ export class OrderEdit extends SoftDeletableEntity {
canceled_at?: Date canceled_at?: Date
// Computed // Computed
subtotal: number shipping_total: number
discount_total?: number discount_total: number
tax_total: number tax_total: number | null
total: number total: number
subtotal: number
gift_card_total: number
gift_card_tax_total: number
difference_due: number difference_due: number
status: OrderEditStatus status: OrderEditStatus
@@ -168,12 +172,24 @@ export class OrderEdit extends SoftDeletableEntity {
* type: string * type: string
* subtotal: * subtotal:
* type: integer * type: integer
* description: The subtotal for line items computed from changes. * description: The total of subtotal
* example: 8000 * example: 8000
* discount_total: * discount_total:
* type: integer * type: integer
* description: The total of discount * description: The total of discount
* example: 800 * 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: * tax_total:
* type: integer * type: integer
* description: The total of tax * description: The total of tax
@@ -2,16 +2,22 @@ import { IdMap } from "medusa-test-utils"
import { MedusaError } from "medusa-core-utils" import { MedusaError } from "medusa-core-utils"
export const LineItemServiceMock = { export const LineItemServiceMock = {
withTransaction: function() { withTransaction: function () {
return this 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 }) return Promise.resolve({ ...data })
}), }),
update: jest.fn().mockImplementation(data => { update: jest.fn().mockImplementation((data) => {
return Promise.resolve({ ...data }) return Promise.resolve({ ...data })
}), }),
validate: jest.fn().mockImplementation(data => { validate: jest.fn().mockImplementation((data) => {
if (data.title === "invalid lineitem") { if (data.title === "invalid lineitem") {
throw new Error(`"content" is required`) throw new Error(`"content" is required`)
} }
@@ -30,7 +30,38 @@ export const orderEditServiceMock = {
return Promise.resolve(undefined) return Promise.resolve(undefined)
}), }),
computeLineItems: jest.fn().mockImplementation((orderEdit) => { 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((_) => { delete: jest.fn().mockImplementation((_) => {
return Promise.resolve() return Promise.resolve()
@@ -1,7 +1,16 @@
import { IdMap, MockManager, MockRepository } from "medusa-test-utils" 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 { OrderEditItemChangeType } from "../../models"
import { OrderServiceMock } from "../__mocks__/order" import { OrderServiceMock } from "../__mocks__/order"
import { EventBusServiceMock } from "../__mocks__/event-bus"
import { LineItemServiceMock } from "../__mocks__/line-item"
import { TotalsServiceMock } from "../__mocks__/totals"
const orderEditWithChanges = { const orderEditWithChanges = {
id: IdMap.getId("order-edit-with-changes"), 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", () => { describe("OrderEditService", () => {
const orderEditRepository = MockRepository({ const orderEditRepository = MockRepository({
findOneWithRelations: (relations, query) => { findOneWithRelations: (relations, query) => {
@@ -57,12 +85,21 @@ describe("OrderEditService", () => {
return {} return {}
}, },
create: (data) => {
return {
...orderEditWithChanges,
...data,
}
},
}) })
const orderEditService = new OrderEditService({ const orderEditService = new OrderEditService({
manager: MockManager, manager: MockManager,
orderEditRepository, orderEditRepository,
orderService: OrderServiceMock as unknown as OrderService, 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 () => { 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 () => { 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") IdMap.getId("order-edit-with-changes")
) )
const { items, removedItems } = await orderEditService.computeLineItems(
orderEdit.id
)
expect(items.length).toBe(2) expect(items.length).toBe(2)
expect(items).toEqual( expect(items).toEqual(
expect.arrayContaining([ 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) }
)
})
}) })
+185 -42
View File
@@ -5,30 +5,49 @@ import { MedusaError } from "medusa-core-utils"
import { OrderEditRepository } from "../repositories/order-edit" import { OrderEditRepository } from "../repositories/order-edit"
import { import {
LineItem, LineItem,
Order,
OrderEdit, OrderEdit,
OrderEditItemChangeType, OrderEditItemChangeType,
OrderEditStatus, OrderEditStatus,
OrderItemChange,
} from "../models" } from "../models"
import { TransactionBaseService } from "../interfaces" import { TransactionBaseService } from "../interfaces"
import { OrderService } from "./index" import {
EventBusService,
LineItemService,
OrderService,
TotalsService,
} from "./index"
import { CreateOrderEditInput } from "../types/order-edit"
type InjectedDependencies = { type InjectedDependencies = {
manager: EntityManager manager: EntityManager
orderEditRepository: typeof OrderEditRepository orderEditRepository: typeof OrderEditRepository
orderService: OrderService orderService: OrderService
eventBusService: EventBusService
totalsService: TotalsService
lineItemService: LineItemService
} }
export default class OrderEditService extends TransactionBaseService { export default class OrderEditService extends TransactionBaseService {
static readonly Events = {
CREATED: "order-edit.created",
}
protected transactionManager_: EntityManager | undefined protected transactionManager_: EntityManager | undefined
protected readonly manager_: EntityManager protected readonly manager_: EntityManager
protected readonly orderEditRepository_: typeof OrderEditRepository protected readonly orderEditRepository_: typeof OrderEditRepository
protected readonly orderService_: OrderService protected readonly orderService_: OrderService
protected readonly lineItemService_: LineItemService
protected readonly eventBusService_: EventBusService
protected readonly totalsService_: TotalsService
constructor({ constructor({
manager, manager,
orderEditRepository, orderEditRepository,
orderService, orderService,
lineItemService,
eventBusService,
totalsService,
}: InjectedDependencies) { }: InjectedDependencies) {
// eslint-disable-next-line prefer-rest-params // eslint-disable-next-line prefer-rest-params
super(arguments[0]) super(arguments[0])
@@ -36,13 +55,17 @@ export default class OrderEditService extends TransactionBaseService {
this.manager_ = manager this.manager_ = manager
this.orderEditRepository_ = orderEditRepository this.orderEditRepository_ = orderEditRepository
this.orderService_ = orderService this.orderService_ = orderService
this.lineItemService_ = lineItemService
this.eventBusService_ = eventBusService
this.totalsService_ = totalsService
} }
async retrieve( async retrieve(
orderEditId: string, orderEditId: string,
config: FindConfig<OrderEdit> = {} config: FindConfig<OrderEdit> = {}
): Promise<OrderEdit | never> { ): Promise<OrderEdit | never> {
const orderEditRepository = this.manager_.getCustomRepository( const manager = this.transactionManager_ ?? this.manager_
const orderEditRepository = manager.getCustomRepository(
this.orderEditRepository_ this.orderEditRepository_
) )
const { relations, ...query } = buildQuery({ id: orderEditId }, config) const { relations, ...query } = buildQuery({ id: orderEditId }, config)
@@ -62,58 +85,178 @@ export default class OrderEditService extends TransactionBaseService {
return orderEdit return orderEdit
} }
protected async retrieveActive(
orderId: string,
config: FindConfig<OrderEdit> = {}
): Promise<OrderEdit | undefined> {
const manager = this.transactionManager_ ?? this.manager_
const orderEditRepository = manager.getCustomRepository(
this.orderEditRepository_
)
const query = buildQuery({ order_id: orderId }, 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( async computeLineItems(
orderEditId: string orderEditId: string
): Promise<{ items: LineItem[]; removedItems: LineItem[] }> { ): Promise<{ items: LineItem[]; removedItems: LineItem[] }> {
const manager = this.transactionManager_ ?? this.manager_
const lineItemServiceTx = this.lineItemService_.withTransaction(manager)
const orderEdit = await this.retrieve(orderEditId, { const orderEdit = await this.retrieve(orderEditId, {
select: ["id", "order_id", "changes", "order"], select: ["id", "order_id", "changes"],
relations: [ relations: ["changes", "changes.original_line_item", "changes.line_item"],
"changes",
"changes.line_item",
"changes.original_line_item",
"order",
"order.items",
],
}) })
const originalItems = orderEdit.order.items
const removedItems: LineItem[] = []
const items: LineItem[] = [] const items: LineItem[] = []
const orderEditRemovedItemsMap: Map<string, LineItem> = new Map()
const orderEditUpdatedItemsMap: Map<string, LineItem> = new Map()
const updatedItems = orderEdit.changes for (const change of orderEdit.changes) {
.map((itemChange) => { const lineItemId =
if (itemChange.type === OrderEditItemChangeType.ITEM_ADD) { change.type === OrderEditItemChangeType.ITEM_REMOVE
items.push(itemChange.line_item as LineItem) ? change.original_line_item_id!
return : change.line_item_id!
}
if (itemChange.type === OrderEditItemChangeType.ITEM_REMOVE) { const lineItem = await lineItemServiceTx.retrieve(lineItemId!, {
removedItems.push({ relations: ["tax_lines", "adjustments"],
...itemChange.original_line_item,
id: itemChange.original_line_item_id,
} as LineItem)
return
}
return [itemChange.original_line_item_id as string, itemChange]
}) })
.filter((change) => !!change) as [string, OrderItemChange][]
const orderEditUpdatedChangesMap: Map<string, OrderItemChange> = new Map( if (change.type === OrderEditItemChangeType.ITEM_REMOVE) {
updatedItems orderEditRemovedItemsMap.set(change.original_line_item_id!, lineItem)
) continue
originalItems.map((item) => {
const itemChange = orderEditUpdatedChangesMap.get(item.id)
if (itemChange) {
items.push({
...itemChange.line_item,
id: itemChange.original_line_item_id,
} as LineItem)
} }
})
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<OrderEdit> {
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<void> { async delete(orderEditId: string): Promise<void> {
+18 -29
View File
@@ -11,7 +11,6 @@ import {
Order, Order,
OrderStatus, OrderStatus,
Payment, Payment,
PaymentSession,
PaymentStatus, PaymentStatus,
Return, Return,
Swap, Swap,
@@ -164,9 +163,8 @@ class OrderService extends TransactionBaseService {
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_) const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
const query = buildQuery(selector, config) const query = buildQuery(selector, config)
const { select, relations, totalsToSelect } = this.transformQueryForTotals( const { select, relations, totalsToSelect } =
config this.transformQueryForTotals(config)
)
if (select && select.length) { if (select && select.length) {
query.select = select query.select = select
@@ -234,9 +232,8 @@ class OrderService extends TransactionBaseService {
} }
} }
const { select, relations, totalsToSelect } = this.transformQueryForTotals( const { select, relations, totalsToSelect } =
config this.transformQueryForTotals(config)
)
if (select && select.length) { if (select && select.length) {
query.select = select query.select = select
@@ -254,9 +251,7 @@ class OrderService extends TransactionBaseService {
return [orders, count] return [orders, count]
} }
protected transformQueryForTotals( protected transformQueryForTotals(config: FindConfig<Order>): {
config: FindConfig<Order>
): {
relations: string[] | undefined relations: string[] | undefined
select: FindConfig<Order>["select"] select: FindConfig<Order>["select"]
totalsToSelect: FindConfig<Order>["select"] totalsToSelect: FindConfig<Order>["select"]
@@ -337,9 +332,8 @@ class OrderService extends TransactionBaseService {
): Promise<Order> { ): Promise<Order> {
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_) const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
const { select, relations, totalsToSelect } = this.transformQueryForTotals( const { select, relations, totalsToSelect } =
config this.transformQueryForTotals(config)
)
const query = { const query = {
where: { id: orderId }, where: { id: orderId },
@@ -378,9 +372,8 @@ class OrderService extends TransactionBaseService {
): Promise<Order> { ): Promise<Order> {
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_) const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
const { select, relations, totalsToSelect } = this.transformQueryForTotals( const { select, relations, totalsToSelect } =
config this.transformQueryForTotals(config)
)
const query = { const query = {
where: { cart_id: cartId }, where: { cart_id: cartId },
@@ -418,9 +411,8 @@ class OrderService extends TransactionBaseService {
): Promise<Order> { ): Promise<Order> {
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_) const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
const { select, relations, totalsToSelect } = this.transformQueryForTotals( const { select, relations, totalsToSelect } =
config this.transformQueryForTotals(config)
)
const query = { const query = {
where: { external_id: externalId }, where: { external_id: externalId },
@@ -858,9 +850,8 @@ class OrderService extends TransactionBaseService {
.withTransaction(manager) .withTransaction(manager)
.createShippingMethod(optionId, data ?? {}, { order, ...config }) .createShippingMethod(optionId, data ?? {}, { order, ...config })
const shippingOptionServiceTx = this.shippingOptionService_.withTransaction( const shippingOptionServiceTx =
manager this.shippingOptionService_.withTransaction(manager)
)
const methods = [newMethod] const methods = [newMethod]
if (shipping_methods.length) { if (shipping_methods.length) {
@@ -1031,9 +1022,8 @@ class OrderService extends TransactionBaseService {
await inventoryServiceTx.adjustInventory(item.variant_id, item.quantity) await inventoryServiceTx.adjustInventory(item.variant_id, item.quantity)
} }
const paymentProviderServiceTx = this.paymentProviderService_.withTransaction( const paymentProviderServiceTx =
manager this.paymentProviderService_.withTransaction(manager)
)
for (const p of order.payments) { for (const p of order.payments) {
await paymentProviderServiceTx.cancelPayment(p) await paymentProviderServiceTx.cancelPayment(p)
} }
@@ -1073,9 +1063,8 @@ class OrderService extends TransactionBaseService {
) )
} }
const paymentProviderServiceTx = this.paymentProviderService_.withTransaction( const paymentProviderServiceTx =
manager this.paymentProviderService_.withTransaction(manager)
)
const payments: Payment[] = [] const payments: Payment[] = []
for (const p of order.payments) { for (const p of order.payments) {
@@ -1228,7 +1217,7 @@ class OrderService extends TransactionBaseService {
const fulfillments = await this.fulfillmentService_ const fulfillments = await this.fulfillmentService_
.withTransaction(manager) .withTransaction(manager)
.createFulfillment( .createFulfillment(
(order as unknown) as CreateFulfillmentOrder, order as unknown as CreateFulfillmentOrder,
itemsToFulfill, itemsToFulfill,
{ {
metadata, metadata,
+13 -13
View File
@@ -243,11 +243,12 @@ class TotalsService extends TransactionBaseService {
TaxInclusivePricingFeatureFlag.key TaxInclusivePricingFeatureFlag.key
) && shippingMethod.includes_tax ) && shippingMethod.includes_tax
totals.original_tax_total = await this.taxCalculationStrategy_.calculate( totals.original_tax_total =
[], await this.taxCalculationStrategy_.calculate(
totals.tax_lines, [],
calculationContext totals.tax_lines,
) calculationContext
)
totals.tax_total = totals.original_tax_total totals.tax_total = totals.original_tax_total
if (includesTax) { if (includesTax) {
@@ -904,11 +905,12 @@ class TotalsService extends TransactionBaseService {
calculationContext calculationContext
) )
calculationContext.allocation_map = {} // Don't account for discounts calculationContext.allocation_map = {} // Don't account for discounts
lineItemTotals.original_tax_total = await this.taxCalculationStrategy_.calculate( lineItemTotals.original_tax_total =
[lineItem], await this.taxCalculationStrategy_.calculate(
lineItemTotals.tax_lines, [lineItem],
calculationContext lineItemTotals.tax_lines,
) calculationContext
)
if ( if (
this.featureFlagRouter_.isFeatureEnabled( this.featureFlagRouter_.isFeatureEnabled(
@@ -986,9 +988,7 @@ class TotalsService extends TransactionBaseService {
* @param cartOrOrder - the cart or order to get gift card amount for * @param cartOrOrder - the cart or order to get gift card amount for
* @return the gift card amount applied to the cart or order * @return the gift card amount applied to the cart or order
*/ */
async getGiftCardTotal( async getGiftCardTotal(cartOrOrder: Cart | Order): Promise<{
cartOrOrder: Cart | Order
): Promise<{
total: number total: number
tax_total: number tax_total: number
}> { }> {
+5
View File
@@ -22,3 +22,8 @@ export const defaultOrderEditFields: (keyof OrderEdit)[] = [
"canceled_at", "canceled_at",
"internal_note", "internal_note",
] ]
export type CreateOrderEditInput = {
order_id: string
internal_note?: string
}