feat: add a line item to an order edit (#2243)

**What**
- Implement adding a line item to order (edit)

**How**
- _by implementing the following "flow"_
  - generate a line item
  - computing line item adjustments for that line item
  - creating tax lines
  - creating a change record

**Testing**
- **_integration tests_**
  - check if line item and order item change objects are created (with correct tax lines)
  - line item adjustments are generated if
    - fixed discount is applied to cart
    - percentage discount is applied
- **_unit tests_** 
  - ensure that methods from Inventory, LineItem, LineItemAdjustment etc. services are called 

---

RESOLVES CORE-495
This commit is contained in:
Frane Polić
2022-09-28 11:51:13 +00:00
committed by GitHub
parent 474e97252c
commit 884f36e8a8
19 changed files with 679 additions and 80 deletions
@@ -3,13 +3,7 @@ import {
DraftOrderService,
LineItemService,
} from "../../../../services"
import {
IsBoolean,
IsInt,
IsObject,
IsOptional,
IsString,
} from "class-validator"
import { IsInt, IsObject, IsOptional, IsString } from "class-validator"
import {
defaultAdminDraftOrdersCartFields,
defaultAdminDraftOrdersCartRelations,
@@ -17,7 +11,6 @@ import {
} from "."
import { EntityManager } from "typeorm"
import { FlagRouter } from "../../../../utils/flag-router"
import { MedusaError } from "medusa-core-utils"
import { validator } from "../../../../utils/validator"
@@ -0,0 +1,101 @@
import { Request, Response } from "express"
import { IsInt, IsOptional, IsString } from "class-validator"
import { EntityManager } from "typeorm"
import { OrderEditService } from "../../../../services"
import {
defaultOrderEditFields,
defaultOrderEditRelations,
} from "../../../../types/order-edit"
/**
* @oas [post] /order-edits/{id}/items
* operationId: "PostOrderEditsEditLineItems"
* summary: "Add an line item to an order (edit)"
* description: "Create an OrderEdit LineItem."
* parameters:
* - (path) id=* {string} The ID of the Order Edit.
* 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.addLineItem(order_edit_id, { variant_id, quantity })
* .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}/items' \
* --header 'Authorization: Bearer {api_token}'
* -d '{ "variant_id": "some_variant_id", "quantity": 3 }'
* 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 { id } = req.params
const manager = req.scope.resolve("manager") as EntityManager
const data = req.validatedBody as AdminPostOrderEditsEditLineItemsReq
await manager.transaction(async (transactionManager) => {
await orderEditService
.withTransaction(transactionManager)
.addLineItem(id, data)
})
let orderEdit = await orderEditService.retrieve(id, {
select: defaultOrderEditFields,
relations: defaultOrderEditRelations,
})
orderEdit = await orderEditService.decorateTotals(orderEdit)
res.status(200).send({
order_edit: orderEdit,
})
}
export class AdminPostOrderEditsEditLineItemsReq {
@IsString()
variant_id: string
@IsInt()
quantity: number
@IsOptional()
metadata?: Record<string, unknown> | undefined
}
@@ -8,7 +8,7 @@ import { OrderEditService } from "../../../../services"
* description: "Deletes an Order Edit"
* x-authenticated: true
* parameters:
* - (path) id=* {string} The ID of the Note to delete.
* - (path) id=* {string} The ID of the Order Edit to delete.
* x-codeSamples:
* - lang: JavaScript
* label: JS Client
@@ -14,6 +14,7 @@ import {
import { OrderEdit } from "../../../../models"
import { AdminPostOrderEditsOrderEditReq } from "./update-order-edit"
import { AdminPostOrderEditsReq } from "./create-order-edit"
import { AdminPostOrderEditsEditLineItemsReq } from "./add-line-item"
import { AdminPostOrderEditsEditLineItemsLineItemReq } from "./update-order-edit-line-item"
const route = Router()
@@ -52,6 +53,12 @@ export default (app) => {
middlewares.wrap(require("./cancel-order-edit").default)
)
route.post(
"/:id/items",
transformBody(AdminPostOrderEditsEditLineItemsReq),
middlewares.wrap(require("./add-line-item").default)
)
route.delete("/:id", middlewares.wrap(require("./delete-order-edit").default))
route.delete(
@@ -86,3 +93,5 @@ export type AdminOrderEditItemChangeDeleteRes = {
export * from "./update-order-edit"
export * from "./update-order-edit-line-item"
export * from "./create-order-edit"
export * from "./add-line-item"
@@ -10,6 +10,9 @@ export const orderEditItemChangeServiceMock = {
order_edit_id: orderEditId,
})
}),
create: jest.fn().mockImplementation((data) => {
return Promise.resolve(data)
}),
delete: jest.fn().mockImplementation(() => {
return Promise.resolve()
}),
@@ -4,7 +4,6 @@ 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", () => {
@@ -77,6 +77,17 @@ const orderEditWithChanges = {
],
}
const orderEditWithAddedLineItem = {
id: IdMap.getId("order-edit-with-changes"),
order: {
id: IdMap.getId("order-edit-change"),
cart: {
discounts: [{ rule: {} }],
},
region: { id: IdMap.getId("test-region") },
},
}
const lineItemServiceMock = {
...LineItemServiceMock,
list: jest.fn().mockImplementation(() => {
@@ -179,9 +190,9 @@ describe("OrderEditService", () => {
lineItemService: lineItemServiceMock as unknown as LineItemService,
orderEditItemChangeService:
orderEditItemChangeServiceMock as unknown as OrderEditItemChangeService,
taxProviderService: taxProviderServiceMock as unknown as TaxProviderService,
lineItemAdjustmentService:
LineItemAdjustmentServiceMock as unknown as LineItemAdjustmentService,
taxProviderService: taxProviderServiceMock as unknown as TaxProviderService,
})
it("should retrieve an order edit and call the repository with the right arguments", async () => {
@@ -392,4 +403,20 @@ describe("OrderEditService", () => {
)
})
})
it("should add a line item to an order edit", async () => {
jest
.spyOn(orderEditService, "refreshAdjustments")
.mockImplementation(async () => {})
await orderEditService.addLineItem(IdMap.getId("order-edit-with-changes"), {
variant_id: IdMap.getId("to-be-added-variant"),
quantity: 3,
})
expect(LineItemServiceMock.generate).toHaveBeenCalledTimes(1)
expect(orderEditService.refreshAdjustments).toHaveBeenCalledTimes(1)
expect(taxProviderServiceMock.createTaxLines).toHaveBeenCalledTimes(1)
expect(orderEditItemChangeServiceMock.create).toHaveBeenCalledTimes(1)
})
})
+1
View File
@@ -16,6 +16,7 @@ export { default as GiftCardService } from "./gift-card"
export { default as IdempotencyKeyService } from "./idempotency-key"
export { default as InventoryService } from "./inventory"
export { default as LineItemService } from "./line-item"
export { default as LineItemAdjustmentService } from "./line-item-adjustment"
export { default as MiddlewareService } from "./middleware"
export { default as NoteService } from "./note"
export { default as NotificationService } from "./notification"
@@ -1,6 +1,6 @@
import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { EntityManager, In } from "typeorm"
import {
Cart,
DiscountRuleType,
@@ -12,6 +12,8 @@ import { LineItemAdjustmentRepository } from "../repositories/line-item-adjustme
import { FindConfig } from "../types/common"
import { FilterableLineItemAdjustmentProps } from "../types/line-item-adjustment"
import DiscountService from "./discount"
import { TransactionBaseService } from "../interfaces"
import { buildQuery, setMetadata } from "../utils"
type LineItemAdjustmentServiceProps = {
manager: EntityManager
@@ -23,46 +25,36 @@ type AdjustmentContext = {
variant: ProductVariant
}
type GeneratedAdjustment = Omit<LineItem, "id" | "item_id">
type GeneratedAdjustment = {
amount: number
discount_id: string
description: string
}
/**
* Provides layer to manipulate line item adjustments.
* @extends BaseService
*/
class LineItemAdjustmentService extends BaseService {
private manager_: EntityManager
private lineItemAdjustmentRepo_: typeof LineItemAdjustmentRepository
private discountService: DiscountService
class LineItemAdjustmentService extends TransactionBaseService {
protected readonly manager_: EntityManager
protected transactionManager_: EntityManager | undefined
private readonly lineItemAdjustmentRepo_: typeof LineItemAdjustmentRepository
private readonly discountService: DiscountService
constructor({
manager,
lineItemAdjustmentRepository,
discountService,
}: LineItemAdjustmentServiceProps) {
super()
// eslint-disable-next-line prefer-rest-params
super(arguments[0])
this.manager_ = manager
this.lineItemAdjustmentRepo_ = lineItemAdjustmentRepository
this.discountService = discountService
}
withTransaction(
transactionManager: EntityManager
): LineItemAdjustmentService {
if (!transactionManager) {
return this
}
const cloned = new LineItemAdjustmentService({
manager: transactionManager,
lineItemAdjustmentRepository: this.lineItemAdjustmentRepo_,
discountService: this.discountService,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
* Retrieves a line item adjustment by id.
* @param id - the id of the line item adjustment to retrieve
@@ -76,7 +68,7 @@ class LineItemAdjustmentService extends BaseService {
const lineItemAdjustmentRepo: LineItemAdjustmentRepository =
this.manager_.getCustomRepository(this.lineItemAdjustmentRepo_)
const query = this.buildQuery_({ id }, config)
const query = buildQuery({ id }, config)
const lineItemAdjustment = await lineItemAdjustmentRepo.findOne(query)
if (!lineItemAdjustment) {
@@ -124,10 +116,7 @@ class LineItemAdjustmentService extends BaseService {
const { metadata, ...rest } = data
if (metadata) {
lineItemAdjustment.metadata = this.setMetadata_(
lineItemAdjustment,
metadata
)
lineItemAdjustment.metadata = setMetadata(lineItemAdjustment, metadata)
}
for (const [key, value] of Object.entries(rest)) {
@@ -153,7 +142,7 @@ class LineItemAdjustmentService extends BaseService {
this.lineItemAdjustmentRepo_
)
const query = this.buildQuery_(selector, config)
const query = buildQuery(selector, config)
return await lineItemAdjustmentRepo.find(query)
}
@@ -172,15 +161,15 @@ class LineItemAdjustmentService extends BaseService {
if (typeof selectorOrIds === "string" || Array.isArray(selectorOrIds)) {
const ids =
typeof selectorOrIds === "string" ? [selectorOrIds] : selectorOrIds
return await lineItemAdjustmentRepo.delete({ id: In(ids) })
await lineItemAdjustmentRepo.delete({ id: In(ids) })
return
}
const query = this.buildQuery_(selectorOrIds)
const query = buildQuery(selectorOrIds)
const lineItemAdjustments = await lineItemAdjustmentRepo.find(query)
await lineItemAdjustmentRepo.remove(lineItemAdjustments)
return
})
}
+14 -8
View File
@@ -2,23 +2,22 @@ import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { EntityManager, In } from "typeorm"
import { DeepPartial } from "typeorm/common/DeepPartial"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { LineItemTaxLine } from "../models"
import { Cart } from "../models/cart"
import { LineItem } from "../models/line-item"
import { LineItemAdjustment } from "../models/line-item-adjustment"
import { CartRepository } from "../repositories/cart"
import { LineItemRepository } from "../repositories/line-item"
import { LineItemTaxLineRepository } from "../repositories/line-item-tax-line"
import { FindConfig } from "../types/common"
import { Cart, LineItemTaxLine, LineItem, LineItemAdjustment } from "../models"
import { FindConfig, Selector } from "../types/common"
import { FlagRouter } from "../utils/flag-router"
import LineItemAdjustmentService from "./line-item-adjustment"
import OrderEditingFeatureFlag from "../loaders/feature-flags/order-editing"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import {
PricingService,
ProductService,
ProductVariantService,
RegionService,
} from "./index"
import LineItemAdjustmentService from "./line-item-adjustment"
type InjectedDependencies = {
manager: EntityManager
@@ -99,7 +98,7 @@ class LineItemService extends BaseService {
}
async list(
selector,
selector: Selector<LineItem>,
config: FindConfig<LineItem> = {
skip: 0,
take: 50,
@@ -208,6 +207,7 @@ class LineItemService extends BaseService {
includes_tax?: boolean
metadata?: Record<string, unknown>
customer_id?: string
order_edit_id?: string
cart?: Cart
} = {}
): Promise<LineItem> {
@@ -267,6 +267,12 @@ class LineItemService extends BaseService {
rawLineItem.includes_tax = unitPriceIncludesTax
}
if (
this.featureFlagRouter_.isFeatureEnabled(OrderEditingFeatureFlag.key)
) {
rawLineItem.order_edit_id = context.order_edit_id || null
}
const lineItemRepo = transactionManager.getCustomRepository(
this.lineItemRepository_
)
@@ -1,6 +1,6 @@
import { TransactionBaseService } from "../interfaces"
import { OrderItemChangeRepository } from "../repositories/order-item-change"
import { EntityManager, In } from "typeorm"
import { DeepPartial, EntityManager, In } from "typeorm"
import { EventBusService, LineItemService } from "./index"
import { FindConfig, Selector } from "../types/common"
import { OrderItemChange } from "../models"
+90 -11
View File
@@ -1,7 +1,8 @@
import { EntityManager, IsNull } from "typeorm"
import { MedusaError } from "medusa-core-utils"
import { FindConfig } from "../types/common"
import { buildQuery, isDefined } from "../utils"
import { MedusaError } from "medusa-core-utils"
import { OrderEditRepository } from "../repositories/order-edit"
import {
Cart,
@@ -13,26 +14,30 @@ import {
import { TransactionBaseService } from "../interfaces"
import {
EventBusService,
LineItemAdjustmentService,
LineItemService,
OrderEditItemChangeService,
OrderService,
TaxProviderService,
TotalsService,
} from "./index"
import { CreateOrderEditInput, UpdateOrderEditInput } from "../types/order-edit"
import region from "./region"
import LineItemAdjustmentService from "./line-item-adjustment"
import {
AddOrderEditLineItemInput,
CreateOrderEditInput,
UpdateOrderEditInput,
} from "../types/order-edit"
type InjectedDependencies = {
manager: EntityManager
orderEditRepository: typeof OrderEditRepository
orderService: OrderService
eventBusService: EventBusService
totalsService: TotalsService
lineItemService: LineItemService
orderEditItemChangeService: OrderEditItemChangeService
lineItemAdjustmentService: LineItemAdjustmentService
eventBusService: EventBusService
taxProviderService: TaxProviderService
lineItemAdjustmentService: LineItemAdjustmentService
orderEditItemChangeService: OrderEditItemChangeService
}
export default class OrderEditService extends TransactionBaseService {
@@ -44,16 +49,18 @@ export default class OrderEditService extends TransactionBaseService {
CANCELED: "order-edit.canceled",
}
protected transactionManager_: EntityManager | undefined
protected readonly manager_: EntityManager
protected transactionManager_: EntityManager | undefined
protected readonly orderEditRepository_: typeof OrderEditRepository
protected readonly orderService_: OrderService
protected readonly totalsService_: TotalsService
protected readonly lineItemService_: LineItemService
protected readonly eventBusService_: EventBusService
protected readonly totalsService_: TotalsService
protected readonly orderEditItemChangeService_: OrderEditItemChangeService
protected readonly lineItemAdjustmentService_: LineItemAdjustmentService
protected readonly taxProviderService_: TaxProviderService
protected readonly lineItemAdjustmentService_: LineItemAdjustmentService
protected readonly orderEditItemChangeService_: OrderEditItemChangeService
constructor({
manager,
@@ -459,6 +466,78 @@ export default class OrderEditService extends TransactionBaseService {
return orderEdit
}
async addLineItem(
orderEditId: string,
data: AddOrderEditLineItemInput
): Promise<void> {
return await this.atomicPhase_(async (manager) => {
const lineItemServiceTx = this.lineItemService_.withTransaction(manager)
const orderEdit = await this.retrieve(orderEditId, {
relations: ["order", "order.region"],
})
if (!OrderEditService.isOrderEditActive(orderEdit)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Can not add an item to the edit with status ${orderEdit.status}`
)
}
const regionId = orderEdit.order.region_id
/**
* Create new line item and refresh adjustments for all cloned order edit items
*/
const lineItemData = await lineItemServiceTx.generate(
data.variant_id,
regionId,
data.quantity,
{
customer_id: orderEdit.order.customer_id,
metadata: data.metadata,
order_edit_id: orderEditId,
}
)
let lineItem = await lineItemServiceTx.create(lineItemData)
lineItem = await lineItemServiceTx.retrieve(lineItem.id)
await this.refreshAdjustments(orderEditId)
/**
* Generate a change record
*/
await this.orderEditItemChangeService_.withTransaction(manager).create({
type: OrderEditItemChangeType.ITEM_ADD,
line_item_id: lineItem.id,
order_edit_id: orderEditId,
})
/**
* Compute tax lines
*/
const localCart = {
...orderEdit.order,
object: "cart",
items: [lineItem],
} as unknown as Cart
const calcContext = await this.totalsService_
.withTransaction(manager)
.getCalculationContext(localCart, {
exclude_shipping: true,
})
await this.taxProviderService_
.withTransaction(manager)
.createTaxLines([lineItem], calcContext)
})
}
async deleteItemChange(
orderEditId: string,
itemChangeId: string
+1 -1
View File
@@ -55,7 +55,7 @@ export type Selector<TEntity> = {
| DateComparisonOperator
| StringComparisonOperator
| NumericalComparisonOperator
| FindOperator<TEntity[key][] | string[]>
| FindOperator<TEntity[key][] | string | string[]>
}
export type TotalField =
+8
View File
@@ -9,6 +9,13 @@ export type CreateOrderEditInput = {
internal_note?: string
}
export type AddOrderEditLineItemInput = {
quantity: number
variant_id: string
metadata?: Record<string, unknown>
}
export type CreateOrderEditItemChangeInput = {
type: OrderEditItemChangeType
order_edit_id: string
@@ -21,6 +28,7 @@ export const defaultOrderEditRelations: string[] = [
"changes.line_item",
"changes.original_line_item",
"items",
"items.adjustments",
"items.tax_lines",
]