diff --git a/.changeset/famous-cycles-reply.md b/.changeset/famous-cycles-reply.md new file mode 100644 index 0000000000..027fdb52da --- /dev/null +++ b/.changeset/famous-cycles-reply.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): preserve custom adjustments when refreshing adjustments diff --git a/integration-tests/api/__tests__/admin/order-edit/order-edit.js b/integration-tests/api/__tests__/admin/order-edit/order-edit.js index 9f8a232aba..b66d7c4dd8 100644 --- a/integration-tests/api/__tests__/admin/order-edit/order-edit.js +++ b/integration-tests/api/__tests__/admin/order-edit/order-edit.js @@ -2699,6 +2699,154 @@ describe("/admin/order-edits", () => { }) }) + describe("Preserve custom adjustments on items change", () => { + let product + let product2 + const orderId = IdMap.getId("order-1") + const prodId1 = IdMap.getId("product-1") + const prodId2 = IdMap.getId("product-2") + const lineItemId1 = IdMap.getId("line-item-1") + + beforeEach(async () => { + await adminSeeder(dbConnection) + + product = await simpleProductFactory(dbConnection, { + id: prodId1, + }) + + product2 = await simpleProductFactory(dbConnection, { + id: prodId2, + }) + + await simpleOrderFactory(dbConnection, { + id: orderId, + email: "test@testson.com", + region: { + id: "test-region", + name: "Test region", + tax_rate: 12.5, + }, + tax_rate: null, + line_items: [ + { + adjustments: [ + { + item_id: lineItemId1, + amount: 200, + description: "custom adjustment that should be persisted", + }, + ], + id: lineItemId1, + variant_id: product.variants[0].id, + quantity: 1, + fulfilled_quantity: 1, + shipped_quantity: 1, + unit_price: 1000, + tax_lines: [ + { + item_id: lineItemId1, + rate: 12.5, + code: "default", + name: "default", + }, + ], + }, + ], + }) + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("preserve custom line item on update item change", async () => { + const api = useApi() + + const { + data: { order_edit }, + } = await api.post( + `/admin/order-edits/`, + { + order_id: orderId, + internal_note: "This is an internal note", + }, + adminHeaders + ) + + const orderEditId = order_edit.id + const updateItemId = order_edit.items.find( + (item) => item.original_item_id === lineItemId1 + ).id + + const response = await api.post( + `/admin/order-edits/${orderEditId}/items/${updateItemId}`, + { quantity: 2 }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.order_edit.changes).toHaveLength(1) + expect(response.data.order_edit).toEqual( + expect.objectContaining({ + id: orderEditId, + changes: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + type: "item_update", + order_edit_id: orderEditId, + original_line_item_id: lineItemId1, + line_item_id: expect.any(String), + line_item: expect.objectContaining({ + id: expect.any(String), + }), + original_line_item: expect.objectContaining({ + id: lineItemId1, + }), + }), + ]), + status: "created", + order_id: orderId, + internal_note: "This is an internal note", + created_by: "admin_user", + items: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + original_item_id: lineItemId1, + order_edit_id: orderEditId, + quantity: 2, + adjustments: [ + expect.objectContaining({ + amount: 200, + description: "custom adjustment that should be persisted", + discount_id: null, + }), + ], + tax_lines: expect.arrayContaining([ + expect.objectContaining({ + rate: 12.5, + name: "default", + code: "default", + }), + ]), + }), + ]), + // 2 items with unit price 1000 and a custom line item adjustment of 200 + gift_card_total: 0, + gift_card_tax_total: 0, + shipping_total: 0, + discount_total: 200, + subtotal: 2 * 1000, + tax_total: (2000 - 200) * 0.125, + total: 1800 * 0.125 + 1800, + }) + ) + }) + }) + describe("DELETE /admin/order-edits/:id/items/:item_id", () => { let product let product2 diff --git a/integration-tests/api/__tests__/line-item-adjustments/index.js b/integration-tests/api/__tests__/line-item-adjustments/index.js index 1a65d84316..72c961adde 100644 --- a/integration-tests/api/__tests__/line-item-adjustments/index.js +++ b/integration-tests/api/__tests__/line-item-adjustments/index.js @@ -207,4 +207,81 @@ describe("Line Item Adjustments", () => { }) }) }) + + describe("When refreshing adjustments make sure that only adjustments associated with a Medusa Discount are deleted", () => { + let cart + let discount + const lineItemId = "line-test" + + beforeEach(async () => { + await cartSeeder(dbConnection) + + discount = await simpleDiscountFactory(dbConnection, { + code: "MEDUSATEST", + id: "discount-test", + rule: { + value: 100, + type: "fixed", + allocation: "total", + }, + regions: ["test-region"], + }) + + cart = await simpleCartFactory( + dbConnection, + { + customer: "test-customer", + id: "cart-test", + line_items: [ + { + id: lineItemId, + variant_id: "test-variant", + cart_id: "cart-test", + unit_price: 1000, + quantity: 1, + adjustments: [ + { + amount: 10, + discount_id: discount.id, + description: "discount", + item_id: lineItemId, + }, + { + // this one shouldn't be deleted because it's + // not associated with a Medusa Discount, i.e. + // it's created by a third party system, for example, + // a custom promotions engine + amount: 20, + description: "custom adjustment without discount", + item_id: lineItemId, + }, + ], + }, + ], + region: "test-region", + }, + 100 + ) + }) + + afterEach(async () => { + await doAfterEach() + }) + + it("Delete only adjustments of the removed discount and keep 'custom' adjustments", async () => { + const api = useApi() + + const response = await api.delete( + `/store/carts/${cart.id}/discounts/${discount.code}` + ) + + expect(response.status).toEqual(200) + expect(response.data.cart.items[0].adjustments.length).toEqual(1) + expect(response.data.cart.items[0].adjustments[0]).toMatchObject({ + amount: 20, + description: "custom adjustment without discount", + item_id: lineItemId, + }) + }) + }) }) diff --git a/integration-tests/api/__tests__/returns/index.js b/integration-tests/api/__tests__/returns/index.js index ab95750efc..a8d80feac8 100644 --- a/integration-tests/api/__tests__/returns/index.js +++ b/integration-tests/api/__tests__/returns/index.js @@ -165,7 +165,9 @@ describe("/admin/orders", () => { * shipping method will have 12.5 rate 1000 * 1.125 = 1125 */ expect(response.data.order.returns[0].refund_amount).toEqual(75) - expect(response.data.order.returns[0].shipping_method.tax_lines).toHaveLength(1) + expect( + response.data.order.returns[0].shipping_method.tax_lines + ).toHaveLength(1) expect(response.data.order.returns[0].shipping_method.tax_lines).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -358,14 +360,16 @@ const createReturnableOrder = async (dbConnection, options) => { fulfilled_quantity: options.shipped ? 2 : undefined, shipped_quantity: options.shipped ? 2 : undefined, unit_price: 1000, - adjustments: [ - { - amount: 200, - discount_code: "TESTCODE", - description: "discount", - item_id: "test-item", - }, - ], + adjustments: options.discount + ? [ + { + amount: 200, + discount_code: "TESTCODE", + description: "discount", + item_id: "test-item", + }, + ] + : [], tax_lines: [ { name: "default", diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index ed4a963f85..6294dfef20 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -21,6 +21,7 @@ import { ShippingOptionServiceMock } from "../__mocks__/shipping-option" import { CustomerServiceMock } from "../__mocks__/customer" import TaxCalculationStrategy from "../../strategies/tax-calculation" import SystemTaxService from "../system-tax" +import { IsNull, Not } from "typeorm" const eventBusService = { emit: jest.fn(), @@ -515,6 +516,7 @@ describe("CartService", () => { expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({ item_id: [IdMap.getId("merger")], + discount_id: expect.objectContaining(Not(IsNull())), }) expect( @@ -745,6 +747,7 @@ describe("CartService", () => { expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({ item_id: [IdMap.getId("itemToRemove")], + discount_id: expect.objectContaining(Not(IsNull())), }) expect( @@ -786,6 +789,7 @@ describe("CartService", () => { expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({ item_id: [IdMap.getId("itemToRemove")], + discount_id: expect.objectContaining(Not(IsNull())), }) expect( @@ -965,6 +969,7 @@ describe("CartService", () => { expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({ item_id: [IdMap.getId("existingUpdate")], + discount_id: expect.objectContaining(Not(IsNull())), }) expect( @@ -2259,6 +2264,7 @@ describe("CartService", () => { expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({ item_id: ["li1", "li2"], + discount_id: expect.objectContaining(Not(IsNull())), }) expect( @@ -2309,6 +2315,7 @@ describe("CartService", () => { expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({ item_id: ["li1", "li2"], + discount_id: expect.objectContaining(Not(IsNull())), }) expect( @@ -2368,6 +2375,7 @@ describe("CartService", () => { expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({ item_id: ["li1", "li2"], + discount_id: expect.objectContaining(Not(IsNull())), }) expect( diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index cdcd88d609..159d8e0265 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -1,6 +1,6 @@ import { isEmpty, isEqual } from "lodash" import { isDefined, MedusaError } from "medusa-core-utils" -import { DeepPartial, EntityManager, In } from "typeorm" +import { DeepPartial, EntityManager, In, IsNull, Not } from "typeorm" import { IPriceSelectionStrategy, TransactionBaseService } from "../interfaces" import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels" import { @@ -2604,6 +2604,7 @@ class CartService extends TransactionBaseService { .withTransaction(transactionManager) .delete({ item_id: nonReturnLineIDs, + discount_id: Not(IsNull()), }) // potentially create/update line item adjustments diff --git a/packages/medusa/src/services/line-item-adjustment.ts b/packages/medusa/src/services/line-item-adjustment.ts index a23184e7ce..e395ac7586 100644 --- a/packages/medusa/src/services/line-item-adjustment.ts +++ b/packages/medusa/src/services/line-item-adjustment.ts @@ -1,5 +1,5 @@ import { isDefined, MedusaError } from "medusa-core-utils" -import { EntityManager, In } from "typeorm" +import { EntityManager, FindOperator, In } from "typeorm" import { Cart, DiscountRuleType, LineItem, LineItemAdjustment } from "../models" import { LineItemAdjustmentRepository } from "../repositories/line-item-adjustment" @@ -153,7 +153,12 @@ class LineItemAdjustmentService extends TransactionBaseService { * @return the result of the delete operation */ async delete( - selectorOrIds: string | string[] | FilterableLineItemAdjustmentProps + selectorOrIds: + | string + | string[] + | (FilterableLineItemAdjustmentProps & { + discount_id?: FindOperator + }) ): Promise { return this.atomicPhase_(async (manager) => { const lineItemAdjustmentRepo: LineItemAdjustmentRepository = diff --git a/packages/medusa/src/services/order-edit.ts b/packages/medusa/src/services/order-edit.ts index 25e60e24d7..4a2d5e9dba 100644 --- a/packages/medusa/src/services/order-edit.ts +++ b/packages/medusa/src/services/order-edit.ts @@ -373,7 +373,9 @@ export default class OrderEditService extends TransactionBaseService { quantity: data.quantity, }) - await this.refreshAdjustments(orderEditId) + await this.refreshAdjustments(orderEditId, { + preserveCustomAdjustments: true, + }) }) } @@ -434,7 +436,10 @@ export default class OrderEditService extends TransactionBaseService { }) } - async refreshAdjustments(orderEditId: string) { + async refreshAdjustments( + orderEditId: string, + config = { preserveCustomAdjustments: false } + ) { const manager = this.transactionManager_ ?? this.manager_ const lineItemAdjustmentServiceTx = @@ -461,7 +466,13 @@ export default class OrderEditService extends TransactionBaseService { orderEdit.items.forEach((item) => { if (item.adjustments?.length) { item.adjustments.forEach((adjustment) => { - clonedItemAdjustmentIds.push(adjustment.id) + const preserveAdjustment = config.preserveCustomAdjustments + ? !!adjustment.discount_id + : true + + if (preserveAdjustment) { + clonedItemAdjustmentIds.push(adjustment.id) + } }) } }) diff --git a/packages/medusa/src/services/totals.ts b/packages/medusa/src/services/totals.ts index 62e89d6803..a0ad963f29 100644 --- a/packages/medusa/src/services/totals.ts +++ b/packages/medusa/src/services/totals.ts @@ -447,26 +447,28 @@ class TotalsService extends TransactionBaseService { const allocationMap: LineAllocationsMap = {} if (!options.exclude_discounts) { - let lineDiscounts: LineDiscountAmount[] = [] - const discount = orderOrCart.discounts?.find( ({ rule }) => rule.type !== DiscountRuleType.FREE_SHIPPING ) - if (discount) { - lineDiscounts = this.getLineDiscounts(orderOrCart, discount) - } + + const lineDiscounts: LineDiscountAmount[] = this.getLineDiscounts( + orderOrCart, + discount + ) for (const ld of lineDiscounts) { + const adjustmentAmount = ld.amount + ld.customAdjustmentsAmount + if (allocationMap[ld.item.id]) { allocationMap[ld.item.id].discount = { - amount: ld.amount, - unit_amount: Math.round(ld.amount / ld.item.quantity), + amount: adjustmentAmount, + unit_amount: Math.round(adjustmentAmount / ld.item.quantity), } } else { allocationMap[ld.item.id] = { discount: { - amount: ld.amount, - unit_amount: Math.round(ld.amount / ld.item.quantity), + amount: adjustmentAmount, + unit_amount: Math.round(adjustmentAmount / ld.item.quantity), }, } } @@ -717,7 +719,7 @@ class TotalsService extends TransactionBaseService { swaps?: Swap[] claims?: ClaimOrder[] }, - discount: Discount + discount?: Discount ): LineDiscountAmount[] { let merged: LineItem[] = [...(cartOrOrder.items ?? [])] @@ -736,18 +738,24 @@ class TotalsService extends TransactionBaseService { return merged.map((item) => { const adjustments = item?.adjustments || [] - const discountAdjustments = adjustments.filter( - (adjustment) => adjustment.discount_id === discount.id + const discountAdjustments = discount + ? adjustments.filter( + (adjustment) => adjustment.discount_id === discount.id + ) + : [] + + const customAdjustments = adjustments.filter( + (adjustment) => adjustment.discount_id === null ) + const sumAdjustments = (total, adjustment) => total + adjustment.amount + return { item, amount: item.allow_discounts - ? discountAdjustments.reduce( - (total, adjustment) => total + adjustment.amount, - 0 - ) + ? discountAdjustments.reduce(sumAdjustments, 0) : 0, + customAdjustmentsAmount: customAdjustments.reduce(sumAdjustments, 0), } }) } @@ -994,16 +1002,6 @@ class TotalsService extends TransactionBaseService { excludeNonDiscounts: true, }) - // we only support having free shipping and one other discount, so first - // find the discount, which is not free shipping. - const discount = cartOrOrder.discounts?.find( - ({ rule }) => rule.type !== DiscountRuleType.FREE_SHIPPING - ) - - if (!discount) { - return 0 - } - const discountTotal = this.getLineItemAdjustmentsTotal(cartOrOrder) if (subtotal < 0) { diff --git a/packages/medusa/src/types/totals.ts b/packages/medusa/src/types/totals.ts index f9eec6014e..a050d7d48d 100644 --- a/packages/medusa/src/types/totals.ts +++ b/packages/medusa/src/types/totals.ts @@ -62,4 +62,5 @@ export type LineDiscount = { export type LineDiscountAmount = { item: LineItem amount: number + customAdjustmentsAmount: number }