fix: Compute "virtual" adjustments for order previews (#13306)

* wip

* add wip

* wip

* reuse action

* finish first draft

* fix tests

* cleanup

* Only compute adjustments when necessary

* Create hot-carrots-look.md

* address comments

* minor tweaks

* fix pay col

* fix test

* wip

* Dwip

* wip

* fix: adjustment typo

* fix: import

* fix: workflow imports

* wip: update test

* feat: upsert versioned  adjustments when previewing order

* fix: revert unique codes change

* fix: order spec test with versioning

* wip: save

* feat: make adjustments work for preview and confirm flow, wip base repo filtering of older version adjustments

* fix: missing populate where

* wip: populate where loading versioned adjustments

* fix: filter out older adjustment versions

* temp: comment adjustments in repo

* test: add adjustment if no version

* wip: configure populate where in order base repository

* fix: rm manual filtering

* fix: revert base repo changes

* fix: revert

* fix: use order item version instead of order version

* fix: rm only in test

* fix: update case spec

* fix: remove sceanrio, wip test with draft promotion

* feat: test correct adjustments when disabling promotion

* feat: complex test case

* feat: test consecutive order edits

* feat: 2 promotions test case with a fixed promo

* feat: migrate existing order line item adjustments to order items latest version

* feat: update dep after merge

* wip: load adjustments separatley

* feat: adjustments collections

* fix: spread result, handle related entity case

* fix: update lock

* feat: make sure version is loaded, refactor, handle related entity case

* fix: check fields

* feat: loading adjustments for list and count

* fix: correct items version field

* fix: rm empty array

* fix: wip order modules spec

* fix: order module specs

* feat: preinit items adjustments

* fix: rm only

* fix: rm only

* chore: cleanup

* fix: migration files

* fix: dont change formatting

* fix: core package build

* chore: more cleanup

* fix: item update util

* fix: duplicate import

* fix: refresh adjustments for exchanges (#13992)

* wip: exchange adjustments

* feat: test - receive items

* feat: finish test case

* fix: casing

* fix(draft-orders, core-flows, orders) refresh adjustments for draft orders (#14025)

* wip: draft orders adjustments refresh

* feat: rewrite to use REPLACE action + test

* fix: rm only

* feat: cleanup old REPLACE actions

* feat: cleanup adjustemnts when 0 promotions

* wip: canceling draft order

* fix: make version arg optional

* fix: restore promotion links

* feat: test reverting on cancelation

* fix: address comments in tests

* wip: fix summary on preview

* fix: get pending diff on preview summary from total

* fix: revert pending diff change

---------

Co-authored-by: fPolic <mainacc.polic@gmail.com>
Co-authored-by: Frane Polić <16856471+fPolic@users.noreply.github.com>
This commit is contained in:
Oli Juhl
2025-11-25 10:41:14 +01:00
committed by GitHub
co-authored by fPolic Frane Polić
parent 0ddd9e36b5
commit 78842af1c3
60 changed files with 3271 additions and 233 deletions
@@ -868,6 +868,86 @@ moduleIntegrationTestRunner<IOrderModuleService>({
})
)
})
it("should create an order change, update items, and have the pending difference updated", async function () {
const createdOrder = await service.createOrders({
email: "foo@bar.com",
items: [
{
title: "Item 1",
subtitle: "Subtitle 1",
thumbnail: "thumbnail1.jpg",
quantity: new BigNumber(1),
product_id: "product1",
product_title: "Product 1",
product_description: "Description 1",
product_subtitle: "Product Subtitle 1",
product_type: "Type 1",
product_collection: "Collection 1",
product_handle: "handle1",
variant_id: "variant1",
variant_sku: "SKU1",
variant_barcode: "Barcode1",
variant_title: "Variant 1",
variant_option_values: {
color: "Red",
size: "Large",
},
requires_shipping: true,
is_discountable: true,
is_tax_inclusive: true,
compare_at_unit_price: 10,
unit_price: 10,
tax_lines: [],
adjustments: [
{
code: "VIP_10",
amount: 1,
description: "VIP discount",
promotion_id: "prom_123",
provider_id: "coupon_kings",
},
],
},
],
sales_channel_id: "test",
transactions: [
{
amount: 9,
currency_code: "USD",
reference: "payment",
reference_id: "pay_123",
},
],
currency_code: "usd",
customer_id: "joe",
} as CreateOrderDTO)
const orderChange = await service.createOrderChange({
order_id: createdOrder.id,
actions: [
{
action: ChangeActionType.ITEM_UPDATE,
details: {
reference_id: createdOrder.items![0].id,
quantity: 0,
},
},
],
})
await service.confirmOrderChange({
id: orderChange.id,
})
const changedOrder = await service.retrieveOrder(createdOrder.id, {
select: ["total", "summary", "total"],
relations: ["items"],
})
// @ts-ignore
expect(changedOrder.summary?.pending_difference.numeric).toEqual(-9)
})
})
},
})
@@ -1978,6 +1978,15 @@
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_order_item_order_id_version\" ON \"order_item\" (\"order_id\", \"version\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_order_item_version",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_order_item_version\" ON \"order_item\" (version) WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_order_item_item_id",
"columnNames": [],
@@ -2048,6 +2057,16 @@
"nullable": false,
"mappedType": "text"
},
"version": {
"name": "version",
"type": "integer",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "1",
"mappedType": "integer"
},
"description": {
"name": "description",
"type": "text",
@@ -0,0 +1,33 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20251016160403 extends Migration {
override async up(): Promise<void> {
this.addSql(
`alter table if exists "order_line_item_adjustment" add column if not exists "version" integer not null default 1;`
)
this.addSql(`
WITH latest_order_item_version AS (
SELECT
oli.id AS item_id,
MAX(oi.version) AS version
FROM "order_line_item" oli
INNER JOIN "order_item" oi
ON oi.item_id = oli.id
AND oi.deleted_at IS NULL
GROUP BY oli.id
)
UPDATE "order_line_item_adjustment" olia
SET version = latest_order_item_version.version
FROM latest_order_item_version
WHERE olia.item_id = latest_order_item_version.item_id
AND olia.version <> latest_order_item_version.version;
`)
}
override async down(): Promise<void> {
this.addSql(
`alter table if exists "order_line_item_adjustment" drop column if exists "version";`
)
}
}
@@ -4,6 +4,7 @@ import { OrderLineItem } from "./line-item"
const _OrderLineItemAdjustment = model
.define("OrderLineItemAdjustment", {
id: model.id({ prefix: "ordliadj" }).primaryKey(),
version: model.number().default(1),
description: model.text().nullable(),
promotion_id: model.text().nullable(),
code: model.text().nullable(),
@@ -2540,19 +2540,29 @@ export default class OrderModuleService
sharedContext
)
const { itemsToUpsert, shippingMethodsToUpsert, calculatedOrders } =
await applyChangesToOrder(
[order],
{ [order.id]: orderChange.actions },
{ addActionReferenceToObject: true }
)
// We need to apply the latest ordering actions last
const sortedActions = orderChange.actions.sort((a, b) => {
return a.ordering - b.ordering
})
const {
itemsToUpsert,
shippingMethodsToUpsert,
calculatedOrders,
lineItemAdjustmentsToCreate,
} = await applyChangesToOrder(
[order],
{ [order.id]: sortedActions },
{ addActionReferenceToObject: true }
)
const calculated = calculatedOrders[order.id]
await this.includeTaxLinesAndAdjustementsToPreview(
await this.includeTaxLinesAndAdjustmentsToPreview(
calculated.order,
itemsToUpsert,
shippingMethodsToUpsert,
lineItemAdjustmentsToCreate, // this will add "virtual" adjustments for the preview version but no actual adjustments will be created in the DB
sharedContext
)
@@ -2568,10 +2578,11 @@ export default class OrderModuleService
return calcOrder
}
private async includeTaxLinesAndAdjustementsToPreview(
private async includeTaxLinesAndAdjustmentsToPreview(
order,
itemsToUpsert,
shippingMethodsToUpsert,
lineItemAdjustmentsToCreate,
sharedContext: Context = {}
) {
const addedItems = {}
@@ -2619,6 +2630,11 @@ export default class OrderModuleService
//@ts-ignore
const newItem = itemsToUpsert.find((d) => d.item_id === item.id)!
const adjustments = lineItemAdjustmentsToCreate.filter(
(d) => d.item_id === newItem.item_id
)
const unitPrice = newItem?.unit_price ?? item.unit_price
const compareAtUnitPrice =
newItem?.compare_at_unit_price ?? item.compare_at_unit_price
@@ -2632,6 +2648,7 @@ export default class OrderModuleService
quantity: newItem.quantity,
unit_price: unitPrice,
compare_at_unit_price: compareAtUnitPrice || null,
adjustments: adjustments,
detail: {
...newItem,
...item,
@@ -3539,11 +3556,12 @@ export default class OrderModuleService
summariesToUpsert,
orderToUpdate,
creditLinesToUpsert,
lineItemAdjustmentsToCreate,
} = await applyChangesToOrder(orders, actionsMap, {
addActionReferenceToObject: true,
includeTaxLinesAndAdjustementsToPreview: async (...args) => {
includeTaxLinesAndAdjustmentsToPreview: async (...args) => {
args.push(sharedContext)
return await this.includeTaxLinesAndAdjustementsToPreview.apply(
return await this.includeTaxLinesAndAdjustmentsToPreview.apply(
this,
args
)
@@ -3582,6 +3600,14 @@ export default class OrderModuleService
sharedContext
)
: null,
lineItemAdjustmentsToCreate.length
? this.orderLineItemAdjustmentService_.create(
// this is called when a new order version is confirmed so we only create a new set of adjustments for that version
// there is no removal or upsert
lineItemAdjustmentsToCreate,
sharedContext
)
: null,
])
return {
@@ -1,6 +1,8 @@
import {
BigNumberInput,
CreateOrderCreditLineDTO,
LineItemAdjustmentDTO,
LineItemTaxLineDTO,
OrderCreditLineDTO,
} from "@medusajs/framework/types"
@@ -13,10 +15,13 @@ export type VirtualOrder = {
return_id?: string
claim_id?: string
exchange_id?: string
is_tax_inclusive?: boolean
unit_price: BigNumberInput
compare_at_unit_price: BigNumberInput | null
quantity: BigNumberInput
adjustments?: (LineItemAdjustmentDTO & { version: number })[]
tax_lines?: LineItemTaxLineDTO[]
detail: {
id?: string
@@ -24,7 +29,6 @@ export type VirtualOrder = {
return_id?: string
claim_id?: string
exchange_id?: string
item_id?: string
unit_price?: BigNumberInput
compare_at_unit_price?: BigNumberInput | null
@@ -5,6 +5,7 @@ export * from "./credit-line-add"
export * from "./deliver-item"
export * from "./fulfill-item"
export * from "./item-add"
export * from "./item-adjustments-replace"
export * from "./item-remove"
export * from "./item-update"
export * from "./promotion-add"
@@ -19,3 +20,4 @@ export * from "./shipping-remove"
export * from "./shipping-update"
export * from "./transfer-customer"
export * from "./write-off-item"
@@ -29,7 +29,6 @@ OrderChangeProcessing.registerActionType(ChangeActionType.ITEM_ADD, {
return_id: action.return_id,
claim_id: action.claim_id,
exchange_id: action.exchange_id,
unit_price: action.details.unit_price,
compare_at_unit_price: action.details.compare_at_unit_price,
quantity: action.details.quantity,
@@ -0,0 +1,32 @@
import { ChangeActionType, MedusaError } from "@medusajs/framework/utils"
import { OrderChangeProcessing } from "../calculate-order-change"
import { setActionReference } from "../set-action-reference"
OrderChangeProcessing.registerActionType(
ChangeActionType.ITEM_ADJUSTMENTS_REPLACE,
{
operation({ action, currentOrder, options }) {
let existing = currentOrder.items.find(
(item) => item.id === action.details.reference_id
)
if (!existing) {
return
}
existing.adjustments = action.details.adjustments ?? []
setActionReference(existing, action, options)
},
validate({ action }) {
const refId = action.details?.reference_id
if (!action.details.adjustments) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Adjustments of item ${refId} must exist.`
)
}
},
}
)
@@ -27,6 +27,10 @@ OrderChangeProcessing.registerActionType(ChangeActionType.ITEM_UPDATE, {
existing.quantity = currentQuantity
existing.detail.quantity = currentQuantity
if (action.details.adjustments) {
existing.adjustments = action.details.adjustments
}
if (action.details.unit_price) {
const currentUnitPrice = MathBN.convert(action.details.unit_price)
const originalTotal = MathBN.mult(originalUnitPrice, originalQuantity)
@@ -1,4 +1,5 @@
import {
CreateOrderLineItemAdjustmentDTO,
InferEntityType,
OrderChangeActionDTO,
OrderDTO,
@@ -25,13 +26,14 @@ export async function applyChangesToOrder(
actionsMap: Record<string, any[]>,
options?: {
addActionReferenceToObject?: boolean
includeTaxLinesAndAdjustementsToPreview?: (...args) => void
includeTaxLinesAndAdjustmentsToPreview?: (...args) => void
}
) {
const itemsToUpsert: InferEntityType<typeof OrderItem>[] = []
const creditLinesToUpsert: InferEntityType<typeof OrderCreditLine>[] = []
const shippingMethodsToUpsert: InferEntityType<typeof OrderShippingMethod>[] =
[]
const lineItemAdjustmentsToCreate: CreateOrderLineItemAdjustmentDTO[] = []
const summariesToUpsert: any[] = []
const orderToUpdate: any[] = []
@@ -94,6 +96,20 @@ export async function applyChangesToOrder(
metadata: orderItem.metadata,
} as any
if (version > order.version) {
item.adjustments?.forEach((adjustment) => {
lineItemAdjustmentsToCreate.push({
item_id: itemId,
version,
amount: adjustment.amount,
description: adjustment.description,
promotion_id: adjustment.promotion_id,
code: adjustment.code,
is_tax_inclusive: adjustment.is_tax_inclusive,
})
})
}
itemsToUpsert.push(itemToUpsert)
}
@@ -157,11 +173,12 @@ export async function applyChangesToOrder(
}
// Including tax lines and adjustments for added items and shipping methods
if (options?.includeTaxLinesAndAdjustementsToPreview) {
await options?.includeTaxLinesAndAdjustementsToPreview(
if (options?.includeTaxLinesAndAdjustmentsToPreview) {
await options?.includeTaxLinesAndAdjustmentsToPreview(
calculated.order,
itemsToUpsert,
shippingMethodsToUpsert
shippingMethodsToUpsert,
lineItemAdjustmentsToCreate
)
decorateCartTotals(calculated.order)
}
@@ -194,6 +211,7 @@ export async function applyChangesToOrder(
}
return {
lineItemAdjustmentsToCreate,
itemsToUpsert,
creditLinesToUpsert,
shippingMethodsToUpsert,
@@ -1,7 +1,8 @@
import { Constructor, Context, DAL } from "@medusajs/framework/types"
import { toMikroORMEntity } from "@medusajs/framework/utils"
import { LoadStrategy } from "@medusajs/framework/mikro-orm/core"
import { Order, OrderClaim } from "@models"
import { Order, OrderClaim, OrderLineItemAdjustment } from "@models"
import { mapRepositoryToOrderModel } from "."
export function setFindMethods<T>(klass: Constructor<T>, entity: any) {
@@ -76,13 +77,43 @@ export function setFindMethods<T>(klass: Constructor<T>, entity: any) {
configurePopulateWhere(config, isRelatedEntity, version)
let loadAdjustments = false
if (config.options.populate.includes("items.item.adjustments")) {
// TODO: handle if populate is an object
loadAdjustments = true
config.options.populate.splice(
config.options.populate.indexOf("items.item.adjustments"),
1
)
config.options.populate.push("items")
config.options.populate.push("items.item")
// make sure version is loaded if adjustments are requested
if (config.options.fields?.some((f) => f.includes("items.item."))) {
config.options.fields.push(
isRelatedEntity ? "order.items.version" : "items.version"
)
}
}
if (!config.options.orderBy) {
config.options.orderBy = { id: "ASC" }
}
config.where ??= {}
return await manager.find(this.entity, config.where, config.options)
const result = await manager.find(this.entity, config.where, config.options)
if (loadAdjustments) {
const orders = !isRelatedEntity
? [...result]
: [...result].map((r) => r.order).filter(Boolean)
await loadItemAdjustments(manager, orders)
}
return result
}
klass.prototype.findAndCount = async function findAndCount(
@@ -136,6 +167,25 @@ export function setFindMethods<T>(klass: Constructor<T>, entity: any) {
const version = config.where.version ?? defaultVersion
delete config.where.version
let loadAdjustments = false
if (config.options.populate.includes("items.item.adjustments")) {
loadAdjustments = true
config.options.populate.splice(
config.options.populate.indexOf("items.item.adjustments"),
1
)
config.options.populate.push("items")
config.options.populate.push("items.item")
// make sure version is loaded if adjustments are requested
if (config.options.fields?.some((f) => f.includes("items.item."))) {
config.options.fields.push(
isRelatedEntity ? "order.items.version" : "items.version"
)
}
}
configurePopulateWhere(
config,
isRelatedEntity,
@@ -148,7 +198,57 @@ export function setFindMethods<T>(klass: Constructor<T>, entity: any) {
config.options.orderBy = { id: "ASC" }
}
return await manager.findAndCount(this.entity, config.where, config.options)
const [result, count] = await manager.findAndCount(
this.entity,
config.where,
config.options
)
if (loadAdjustments) {
const orders = !isRelatedEntity
? [...result]
: [...result].map((r) => r.order).filter(Boolean)
await loadItemAdjustments(manager, orders)
}
return [result, count]
}
}
/**
* Load adjustment for the lates items/order version
* @param manager MikroORM manager
* @param orders Orders to load adjustments for
*/
async function loadItemAdjustments(manager, orders) {
const items = orders.flatMap((r) => [...(r.items ?? [])])
const itemsIdMap = new Map<string, any>(items.map((i) => [i.item.id, i.item]))
const params = items.map((i) => {
// preinitialise all items so an empty array is returned for ones without adjustments
if (!i.item.adjustments.isInitialized()) {
i.item.adjustments.initialized = true
}
if (!i.version) {
throw new Error("Item version is required to load adjustments")
}
return {
item_id: i.item.id,
version: i.version,
}
})
const adjustments = await manager.find(OrderLineItemAdjustment, {
$or: params,
})
for (const adjustment of adjustments) {
const item = itemsIdMap.get(adjustment.item_id)
if (item) {
item.adjustments.add(adjustment)
}
}
}
@@ -228,6 +228,8 @@ export class OrderChangeProcessing {
public getSummaryFromOrder(order: OrderDTO): OrderSummaryDTO {
const summary_ = this.summary
const total = order.total
// const pendingDifference = MathBN.sub(total, summary_.transaction_total)
const orderSummary = {
transaction_total: new BigNumber(summary_.transaction_total),
original_order_total: new BigNumber(summary_.original_order_total),
@@ -725,6 +725,7 @@ export default class PromotionModuleService
action: ComputedActions.REMOVE_ITEM_ADJUSTMENT,
adjustment_id: adjustment.id,
code,
item_id: adjustment.item_id as string,
})
}
@@ -733,11 +734,12 @@ export default class PromotionModuleService
action: ComputedActions.REMOVE_SHIPPING_METHOD_ADJUSTMENT,
adjustment_id: adjustment.id,
code,
shipping_method_id: adjustment.shipping_method_id as string,
})
}
}
const promotionCodeSet = new Set<string>(promotionCodes)
const promotionCodeSet = new Set<string>(promotionCodes) // TODO: uniquePromotionCodes
const automaticPromotionCodeSet = new Set<string>(automaticPromotionCodes)
const sortedPromotionsToApply = promotions