fix: Cart operation should calculate item prices accounting for quantity (#13251)
* fix(): Cart operation should calculate item prices accounting for quantity * fix(): Cart operation should calculate item prices accounting for quantity * fix(): Cart operation should calculate item prices accounting for quantity * fix when call warning * fix tests and remove unnecessary object copy * Create warm-dancers-allow.md * fix update line item in cart workflow * fix changeset * update order flows * fix cart spec integration tests * improve create order workflow * fixes and tests adjustments/improvements * configurable useQueryGraphStep return type * revert nullable take * cleanup useQueryGraphStep
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@medusajs/core-flows": patch
|
||||
"@medusajs/types": patch
|
||||
---
|
||||
|
||||
fix(): Cart operation should calculate item prices accounting for quantity
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Modules,
|
||||
PriceListStatus,
|
||||
PriceListType,
|
||||
ProductStatus,
|
||||
PromotionRuleOperator,
|
||||
PromotionStatus,
|
||||
PromotionType,
|
||||
@@ -190,6 +191,100 @@ medusaIntegrationTestRunner({
|
||||
)
|
||||
})
|
||||
|
||||
it("should successfully create a cart with a line item with quantity and calculate prices based on the correct quantity", async () => {
|
||||
const productData = {
|
||||
title: "Medusa T-Shirt based quantity",
|
||||
handle: "t-shirt-with-quantity-prices",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
options: [
|
||||
{
|
||||
title: "Size",
|
||||
values: ["S"],
|
||||
},
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "S",
|
||||
sku: "SHIRT-S-BLACK-w-quantity-prices",
|
||||
options: {
|
||||
Size: "S",
|
||||
},
|
||||
manage_inventory: false,
|
||||
prices: [
|
||||
{
|
||||
amount: 1500,
|
||||
currency_code: "usd",
|
||||
min_quantity: 1,
|
||||
max_quantity: 4,
|
||||
},
|
||||
{
|
||||
amount: 1000,
|
||||
currency_code: "usd",
|
||||
min_quantity: 5,
|
||||
max_quantity: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const newProduct = await api.post(
|
||||
`/admin/products`,
|
||||
productData,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const variantId = newProduct.data.product.variants[0].id
|
||||
|
||||
const newCart = (
|
||||
await api.post(
|
||||
`/store/carts`,
|
||||
{
|
||||
currency_code: "usd",
|
||||
sales_channel_id: salesChannel.id,
|
||||
region_id: region.id,
|
||||
shipping_address: shippingAddressData,
|
||||
items: [{ variant_id: variantId, quantity: 6 }],
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
).data.cart
|
||||
|
||||
expect(newCart).toEqual(
|
||||
expect.objectContaining({
|
||||
item_subtotal: 5714.285714285715,
|
||||
item_tax_total: 285.7142857142857,
|
||||
item_total: 6000,
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
quantity: 6,
|
||||
title: "Medusa T-Shirt based quantity",
|
||||
unit_price: 1000,
|
||||
updated_at: expect.any(String),
|
||||
variant_barcode: null,
|
||||
variant_id: expect.any(String),
|
||||
variant_sku: "SHIRT-S-BLACK-w-quantity-prices",
|
||||
variant_title: "S",
|
||||
}),
|
||||
],
|
||||
original_item_subtotal: 5714.285714285715,
|
||||
original_item_tax_total: 285.7142857142857,
|
||||
original_item_total: 6000,
|
||||
original_shipping_subtotal: 0,
|
||||
original_shipping_tax_total: 0,
|
||||
original_shipping_total: 0,
|
||||
original_tax_total: 285.7142857142857,
|
||||
original_total: 6000,
|
||||
shipping_subtotal: 0,
|
||||
shipping_tax_total: 0,
|
||||
shipping_total: 0,
|
||||
subtotal: 5714.285714285715,
|
||||
tax_total: 285.7142857142857,
|
||||
total: 6000,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe("with sale price lists", () => {
|
||||
let priceList
|
||||
|
||||
@@ -442,6 +537,473 @@ medusaIntegrationTestRunner({
|
||||
)
|
||||
})
|
||||
|
||||
it("should add item to cart and calculate prices based on item quantity", async () => {
|
||||
const productData = {
|
||||
title: "Medusa T-Shirt based quantity",
|
||||
handle: "t-shirt-with-quantity-prices",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
options: [
|
||||
{
|
||||
title: "Size",
|
||||
values: ["S"],
|
||||
},
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "S",
|
||||
sku: "SHIRT-S-BLACK-w-quantity-prices",
|
||||
options: {
|
||||
Size: "S",
|
||||
},
|
||||
manage_inventory: false,
|
||||
prices: [
|
||||
{
|
||||
amount: 1500,
|
||||
currency_code: "usd",
|
||||
min_quantity: 1,
|
||||
max_quantity: 4,
|
||||
},
|
||||
{
|
||||
amount: 1000,
|
||||
currency_code: "usd",
|
||||
min_quantity: 5,
|
||||
max_quantity: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const newProduct = await api.post(
|
||||
`/admin/products`,
|
||||
productData,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const variantId = newProduct.data.product.variants[0].id
|
||||
|
||||
const newCart = (
|
||||
await api.post(
|
||||
`/store/carts`,
|
||||
{
|
||||
currency_code: "usd",
|
||||
sales_channel_id: salesChannel.id,
|
||||
region_id: region.id,
|
||||
shipping_address: shippingAddressData,
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
).data.cart
|
||||
|
||||
/**
|
||||
* Add item to cart with quantity 1
|
||||
* in order to have the price calculated based on the price rule
|
||||
* with min_quantity 1 and max_quantity 4
|
||||
*/
|
||||
|
||||
let response = await api.post(
|
||||
`/store/carts/${newCart.id}/line-items`,
|
||||
{
|
||||
variant_id: variantId,
|
||||
quantity: 1,
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.cart).toEqual(
|
||||
expect.objectContaining({
|
||||
billing_address: null,
|
||||
completed_at: null,
|
||||
created_at: expect.any(String),
|
||||
credit_line_subtotal: 0,
|
||||
credit_line_tax_total: 0,
|
||||
credit_line_total: 0,
|
||||
credit_lines: [],
|
||||
currency_code: "usd",
|
||||
customer_id: null,
|
||||
discount_subtotal: 0,
|
||||
discount_tax_total: 0,
|
||||
discount_total: 0,
|
||||
email: null,
|
||||
id: newCart.id,
|
||||
item_subtotal: 1428.5714285714287,
|
||||
item_tax_total: 71.42857142857143,
|
||||
item_total: 1500,
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
compare_at_unit_price: null,
|
||||
created_at: expect.any(String),
|
||||
id: expect.any(String),
|
||||
is_tax_inclusive: true,
|
||||
metadata: {},
|
||||
product: expect.objectContaining({
|
||||
categories: [],
|
||||
collection_id: null,
|
||||
id: expect.any(String),
|
||||
tags: [],
|
||||
type_id: null,
|
||||
}),
|
||||
product_collection: null,
|
||||
product_description: null,
|
||||
product_handle: "t-shirt-with-quantity-prices",
|
||||
product_id: expect.any(String),
|
||||
product_subtitle: null,
|
||||
product_title: "Medusa T-Shirt based quantity",
|
||||
product_type: null,
|
||||
product_type_id: null,
|
||||
quantity: 1,
|
||||
requires_shipping: false,
|
||||
tax_lines: [
|
||||
{
|
||||
code: "CADEFAULT",
|
||||
description: "CA Default Rate",
|
||||
id: expect.any(String),
|
||||
provider_id: "system",
|
||||
rate: 5,
|
||||
},
|
||||
],
|
||||
thumbnail: null,
|
||||
title: "Medusa T-Shirt based quantity",
|
||||
unit_price: 1500,
|
||||
updated_at: expect.any(String),
|
||||
variant_barcode: null,
|
||||
variant_id: expect.any(String),
|
||||
variant_sku: "SHIRT-S-BLACK-w-quantity-prices",
|
||||
variant_title: "S",
|
||||
}),
|
||||
],
|
||||
metadata: null,
|
||||
original_item_subtotal: 1428.5714285714287,
|
||||
original_item_tax_total: 71.42857142857143,
|
||||
original_item_total: 1500,
|
||||
original_shipping_subtotal: 0,
|
||||
original_shipping_tax_total: 0,
|
||||
original_shipping_total: 0,
|
||||
original_tax_total: 71.42857142857143,
|
||||
original_total: 1500,
|
||||
region: expect.objectContaining({
|
||||
automatic_taxes: true,
|
||||
countries: expect.any(Array),
|
||||
currency_code: "usd",
|
||||
id: expect.any(String),
|
||||
name: "US",
|
||||
}),
|
||||
region_id: expect.any(String),
|
||||
sales_channel_id: expect.any(String),
|
||||
shipping_address: expect.objectContaining({
|
||||
address_1: "test address 1",
|
||||
address_2: "test address 2",
|
||||
city: "SF",
|
||||
company: null,
|
||||
country_code: "US",
|
||||
first_name: null,
|
||||
id: expect.any(String),
|
||||
last_name: null,
|
||||
phone: null,
|
||||
postal_code: "94016",
|
||||
province: "CA",
|
||||
}),
|
||||
shipping_address_id: expect.any(String),
|
||||
shipping_methods: [],
|
||||
shipping_subtotal: 0,
|
||||
shipping_tax_total: 0,
|
||||
shipping_total: 0,
|
||||
subtotal: 1428.5714285714287,
|
||||
tax_total: 71.42857142857143,
|
||||
total: 1500,
|
||||
updated_at: expect.any(String),
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Add item to cart with quantity 5
|
||||
* in order to have the price calculated based on the price rule
|
||||
* with min_quantity 5 and max_quantity 10
|
||||
*/
|
||||
|
||||
response = await api.post(
|
||||
`/store/carts/${newCart.id}/line-items`,
|
||||
{
|
||||
variant_id: variantId,
|
||||
quantity: 5,
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.cart).toEqual(
|
||||
expect.objectContaining({
|
||||
billing_address: null,
|
||||
completed_at: null,
|
||||
created_at: expect.any(String),
|
||||
credit_line_subtotal: 0,
|
||||
credit_line_tax_total: 0,
|
||||
credit_line_total: 0,
|
||||
credit_lines: [],
|
||||
currency_code: "usd",
|
||||
customer_id: null,
|
||||
discount_subtotal: 0,
|
||||
discount_tax_total: 0,
|
||||
discount_total: 0,
|
||||
email: null,
|
||||
id: newCart.id,
|
||||
item_subtotal: 5714.285714285715,
|
||||
item_tax_total: 285.7142857142857,
|
||||
item_total: 6000,
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
adjustments: [],
|
||||
compare_at_unit_price: null,
|
||||
created_at: expect.any(String),
|
||||
id: expect.any(String),
|
||||
is_tax_inclusive: true,
|
||||
metadata: {},
|
||||
product: {
|
||||
categories: [],
|
||||
collection_id: null,
|
||||
id: expect.any(String),
|
||||
tags: [],
|
||||
type_id: null,
|
||||
},
|
||||
product_collection: null,
|
||||
product_description: null,
|
||||
product_handle: "t-shirt-with-quantity-prices",
|
||||
product_id: expect.any(String),
|
||||
product_subtitle: null,
|
||||
product_title: "Medusa T-Shirt based quantity",
|
||||
product_type: null,
|
||||
product_type_id: null,
|
||||
quantity: 6,
|
||||
requires_shipping: false,
|
||||
tax_lines: [
|
||||
{
|
||||
code: "CADEFAULT",
|
||||
description: "CA Default Rate",
|
||||
id: expect.any(String),
|
||||
provider_id: "system",
|
||||
rate: 5,
|
||||
},
|
||||
],
|
||||
thumbnail: null,
|
||||
title: "Medusa T-Shirt based quantity",
|
||||
unit_price: 1000,
|
||||
updated_at: expect.any(String),
|
||||
variant_barcode: null,
|
||||
variant_id: expect.any(String),
|
||||
variant_sku: "SHIRT-S-BLACK-w-quantity-prices",
|
||||
variant_title: "S",
|
||||
}),
|
||||
],
|
||||
metadata: null,
|
||||
original_item_subtotal: 5714.285714285715,
|
||||
original_item_tax_total: 285.7142857142857,
|
||||
original_item_total: 6000,
|
||||
original_shipping_subtotal: 0,
|
||||
original_shipping_tax_total: 0,
|
||||
original_shipping_total: 0,
|
||||
original_tax_total: 285.7142857142857,
|
||||
original_total: 6000,
|
||||
promotions: [],
|
||||
region: {
|
||||
automatic_taxes: true,
|
||||
countries: expect.any(Array),
|
||||
currency_code: "usd",
|
||||
id: expect.any(String),
|
||||
name: "US",
|
||||
},
|
||||
region_id: expect.any(String),
|
||||
sales_channel_id: expect.any(String),
|
||||
shipping_address: {
|
||||
address_1: "test address 1",
|
||||
address_2: "test address 2",
|
||||
city: "SF",
|
||||
company: null,
|
||||
country_code: "US",
|
||||
first_name: null,
|
||||
id: expect.any(String),
|
||||
last_name: null,
|
||||
phone: null,
|
||||
postal_code: "94016",
|
||||
province: "CA",
|
||||
},
|
||||
shipping_address_id: expect.any(String),
|
||||
shipping_methods: [],
|
||||
shipping_subtotal: 0,
|
||||
shipping_tax_total: 0,
|
||||
shipping_total: 0,
|
||||
subtotal: 5714.285714285715,
|
||||
tax_total: 285.7142857142857,
|
||||
total: 6000,
|
||||
updated_at: expect.any(String),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should update a cart line item quantity and calculate prices based the new item quantity", async () => {
|
||||
const productData = {
|
||||
title: "Medusa T-Shirt based quantity",
|
||||
handle: "t-shirt-with-quantity-prices",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
options: [
|
||||
{
|
||||
title: "Size",
|
||||
values: ["S"],
|
||||
},
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "S",
|
||||
sku: "SHIRT-S-BLACK-w-quantity-prices",
|
||||
options: {
|
||||
Size: "S",
|
||||
},
|
||||
manage_inventory: false,
|
||||
prices: [
|
||||
{
|
||||
amount: 1500,
|
||||
currency_code: "usd",
|
||||
min_quantity: 1,
|
||||
max_quantity: 4,
|
||||
},
|
||||
{
|
||||
amount: 1000,
|
||||
currency_code: "usd",
|
||||
min_quantity: 5,
|
||||
max_quantity: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const newProduct = await api.post(
|
||||
`/admin/products`,
|
||||
productData,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const variantId = newProduct.data.product.variants[0].id
|
||||
|
||||
const newCart = (
|
||||
await api.post(
|
||||
`/store/carts`,
|
||||
{
|
||||
currency_code: "usd",
|
||||
sales_channel_id: salesChannel.id,
|
||||
region_id: region.id,
|
||||
shipping_address: shippingAddressData,
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
).data.cart
|
||||
|
||||
/**
|
||||
* Add item to cart with quantity 1
|
||||
* in order to have the price calculated based on the price rule
|
||||
* with min_quantity 1 and max_quantity 4
|
||||
*/
|
||||
|
||||
let response = await api.post(
|
||||
`/store/carts/${newCart.id}/line-items`,
|
||||
{
|
||||
variant_id: variantId,
|
||||
quantity: 1,
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.cart).toEqual(
|
||||
expect.objectContaining({
|
||||
item_subtotal: 1428.5714285714287,
|
||||
item_tax_total: 71.42857142857143,
|
||||
item_total: 1500,
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
quantity: 1,
|
||||
title: "Medusa T-Shirt based quantity",
|
||||
unit_price: 1500,
|
||||
updated_at: expect.any(String),
|
||||
variant_barcode: null,
|
||||
variant_id: expect.any(String),
|
||||
variant_sku: "SHIRT-S-BLACK-w-quantity-prices",
|
||||
variant_title: "S",
|
||||
}),
|
||||
],
|
||||
original_item_subtotal: 1428.5714285714287,
|
||||
original_item_tax_total: 71.42857142857143,
|
||||
original_item_total: 1500,
|
||||
original_shipping_subtotal: 0,
|
||||
original_shipping_tax_total: 0,
|
||||
original_shipping_total: 0,
|
||||
original_tax_total: 71.42857142857143,
|
||||
original_total: 1500,
|
||||
shipping_subtotal: 0,
|
||||
shipping_tax_total: 0,
|
||||
shipping_total: 0,
|
||||
subtotal: 1428.5714285714287,
|
||||
tax_total: 71.42857142857143,
|
||||
total: 1500,
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* update item quantity to 5
|
||||
* in order to have the price calculated based on the price rule
|
||||
* with min_quantity 5 and max_quantity 10
|
||||
*/
|
||||
|
||||
const itemId = response.data.cart.items[0].id
|
||||
response = await api
|
||||
.post(
|
||||
`/store/carts/${newCart.id}/line-items/${itemId}`,
|
||||
{
|
||||
quantity: 6,
|
||||
},
|
||||
storeHeaders
|
||||
)
|
||||
.catch((e) => {
|
||||
console.log(e.response.data)
|
||||
throw e
|
||||
})
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.cart).toEqual(
|
||||
expect.objectContaining({
|
||||
item_subtotal: 5714.285714285715,
|
||||
item_tax_total: 285.7142857142857,
|
||||
item_total: 6000,
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
quantity: 6,
|
||||
title: "Medusa T-Shirt based quantity",
|
||||
unit_price: 1000,
|
||||
updated_at: expect.any(String),
|
||||
variant_barcode: null,
|
||||
variant_id: expect.any(String),
|
||||
variant_sku: "SHIRT-S-BLACK-w-quantity-prices",
|
||||
variant_title: "S",
|
||||
}),
|
||||
],
|
||||
original_item_subtotal: 5714.285714285715,
|
||||
original_item_tax_total: 285.7142857142857,
|
||||
original_item_total: 6000,
|
||||
original_shipping_subtotal: 0,
|
||||
original_shipping_tax_total: 0,
|
||||
original_shipping_total: 0,
|
||||
original_tax_total: 285.7142857142857,
|
||||
original_total: 6000,
|
||||
shipping_subtotal: 0,
|
||||
shipping_tax_total: 0,
|
||||
shipping_total: 0,
|
||||
subtotal: 5714.285714285715,
|
||||
tax_total: 285.7142857142857,
|
||||
total: 6000,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should remove promotions when promotion is no longer in active state", async () => {
|
||||
let responseBeforePromotionUpdate = await api.post(
|
||||
`/store/carts/${cart.id}/line-items`,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
|
||||
import { Modules, PromotionStatus, PromotionType } from "@medusajs/utils"
|
||||
import { createAdminUser, generatePublishableKey, generateStoreHeaders, } from "../../../../helpers/create-admin-user"
|
||||
import {
|
||||
createAdminUser,
|
||||
generatePublishableKey,
|
||||
generateStoreHeaders,
|
||||
} from "../../../../helpers/create-admin-user"
|
||||
import { setupTaxStructure } from "../../../../modules/__tests__/fixtures/tax"
|
||||
import { medusaTshirtProduct } from "../../../__fixtures__/product"
|
||||
|
||||
@@ -2469,7 +2473,8 @@ medusaIntegrationTestRunner({
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "shipping_option_type",
|
||||
value: "shipping_methods.shipping_option.shipping_option_type_id",
|
||||
value:
|
||||
"shipping_methods.shipping_option.shipping_option_type_id",
|
||||
label: "Shipping Option Type",
|
||||
required: false,
|
||||
field_type: "multiselect",
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
IRegionModuleService,
|
||||
ISalesChannelModuleService,
|
||||
IStockLocationService,
|
||||
PricingContext,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
@@ -759,11 +760,23 @@ medusaIntegrationTestRunner({
|
||||
* Tried jest, but for some reasons it is not able to provide
|
||||
* correct arguments passed to the function
|
||||
*/
|
||||
let pricingContext: any
|
||||
const originalFn = pricingModule.listPriceSets.bind(pricingModule)
|
||||
pricingModule.listPriceSets = function () {
|
||||
pricingContext = { ...arguments[0].context }
|
||||
return originalFn.bind(pricingModule)(...arguments)
|
||||
let calculatePricesHasBeenCalled = false
|
||||
|
||||
const originalFn = pricingModule.calculatePrices.bind(pricingModule)
|
||||
pricingModule.calculatePrices = function (...args) {
|
||||
calculatePricesHasBeenCalled = true
|
||||
|
||||
const pricingContext = args[1]!.context
|
||||
|
||||
expect(pricingContext).toEqual(
|
||||
expect.objectContaining({
|
||||
unit_price: 100,
|
||||
region_id: region.id,
|
||||
currency_code: "usd",
|
||||
})
|
||||
)
|
||||
|
||||
return originalFn.bind(pricingModule)(...args)
|
||||
}
|
||||
|
||||
const { result } = await createCartWorkflow(appContainer).run({
|
||||
@@ -782,15 +795,9 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
|
||||
setPricingContextHook = undefined
|
||||
pricingModule.listPriceSets = originalFn
|
||||
pricingModule.calculatePrices = originalFn
|
||||
|
||||
expect(pricingContext).toEqual(
|
||||
expect.objectContaining({
|
||||
unit_price: 100,
|
||||
region_id: region.id,
|
||||
currency_code: "usd",
|
||||
})
|
||||
)
|
||||
expect(calculatePricesHasBeenCalled).toBe(true)
|
||||
|
||||
const cart = await cartModuleService.retrieveCart(result.id, {
|
||||
relations: ["items"],
|
||||
@@ -924,11 +931,25 @@ medusaIntegrationTestRunner({
|
||||
* Tried jest, but for some reasons it is not able to provide
|
||||
* correct arguments passed to the function
|
||||
*/
|
||||
let pricingContext: any
|
||||
const originalFn = pricingModule.listPriceSets.bind(pricingModule)
|
||||
pricingModule.listPriceSets = function () {
|
||||
pricingContext = { ...arguments[0].context }
|
||||
return originalFn.bind(pricingModule)(...arguments)
|
||||
let calculatePricesHasBeenCalled = false
|
||||
|
||||
const originalFn = pricingModule.calculatePrices.bind(pricingModule)
|
||||
pricingModule.calculatePrices = function (...args) {
|
||||
calculatePricesHasBeenCalled = true
|
||||
|
||||
const pricingContext = args[1]!.context
|
||||
|
||||
expect(pricingContext).toEqual(
|
||||
expect.objectContaining({
|
||||
unit_price: 200,
|
||||
region_id: region.id,
|
||||
currency_code: "usd",
|
||||
})
|
||||
)
|
||||
expect(pricingContext?.customer_id).toBeDefined()
|
||||
expect(pricingContext?.customer_id).not.toEqual("1")
|
||||
|
||||
return originalFn.bind(pricingModule)(...args)
|
||||
}
|
||||
|
||||
const { result } = await createCartWorkflow(appContainer).run({
|
||||
@@ -947,17 +968,9 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
|
||||
setPricingContextHook = undefined
|
||||
pricingModule.listPriceSets = originalFn
|
||||
pricingModule.calculatePrices = originalFn
|
||||
|
||||
expect(pricingContext).toEqual(
|
||||
expect.objectContaining({
|
||||
unit_price: 200,
|
||||
region_id: region.id,
|
||||
currency_code: "usd",
|
||||
})
|
||||
)
|
||||
expect(pricingContext.customer_id).toBeDefined()
|
||||
expect(pricingContext.customer_id).not.toEqual("1")
|
||||
expect(calculatePricesHasBeenCalled).toBe(true)
|
||||
|
||||
const cart = await cartModuleService.retrieveCart(result.id, {
|
||||
relations: ["items"],
|
||||
@@ -1851,7 +1864,7 @@ medusaIntegrationTestRunner({
|
||||
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
action: "validate-variant-prices",
|
||||
action: "get-variant-price-sets",
|
||||
handlerType: "invoke",
|
||||
error: expect.objectContaining({
|
||||
message: expect.stringContaining(
|
||||
@@ -1960,10 +1973,22 @@ medusaIntegrationTestRunner({
|
||||
* correct arguments passed to the function
|
||||
*/
|
||||
let pricingContext: any
|
||||
const originalFn = pricingModule.listPriceSets.bind(pricingModule)
|
||||
pricingModule.listPriceSets = function () {
|
||||
pricingContext = { ...arguments[0].context }
|
||||
return originalFn.bind(pricingModule)(...arguments)
|
||||
let calculatePricessHaveBeenCalled = false
|
||||
const originalFn = pricingModule.calculatePrices.bind(pricingModule)
|
||||
pricingModule.calculatePrices = function (...args) {
|
||||
pricingContext = args[1]!
|
||||
calculatePricessHaveBeenCalled = true
|
||||
|
||||
expect(pricingContext).toEqual(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
unit_price: 100,
|
||||
currency_code: "usd",
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
return originalFn.bind(pricingModule)(...args)
|
||||
}
|
||||
|
||||
await addToCartWorkflow(appContainer).run({
|
||||
@@ -1979,14 +2004,9 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
|
||||
setPricingContextHook = undefined
|
||||
pricingModule.listPriceSets = originalFn
|
||||
pricingModule.calculatePrices = originalFn
|
||||
|
||||
expect(pricingContext).toEqual(
|
||||
expect.objectContaining({
|
||||
unit_price: 100,
|
||||
currency_code: "usd",
|
||||
})
|
||||
)
|
||||
expect(calculatePricessHaveBeenCalled).toBe(true)
|
||||
|
||||
cart = await cartModuleService.retrieveCart(cart.id, {
|
||||
relations: ["items"],
|
||||
@@ -2114,9 +2134,24 @@ medusaIntegrationTestRunner({
|
||||
* correct arguments passed to the function
|
||||
*/
|
||||
let pricingContext: any
|
||||
const originalFn = pricingModule.listPriceSets.bind(pricingModule)
|
||||
pricingModule.listPriceSets = function () {
|
||||
pricingContext = { ...arguments[0].context }
|
||||
let calculatePricessHaveBeenCalled = false
|
||||
|
||||
const originalFn = pricingModule.calculatePrices.bind(pricingModule)
|
||||
pricingModule.calculatePrices = function (...args) {
|
||||
pricingContext = args[1]!
|
||||
calculatePricessHaveBeenCalled = true
|
||||
|
||||
expect(pricingContext).toEqual(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
unit_price: 200,
|
||||
region_id: cart.region_id,
|
||||
customer_id: cart.customer_id,
|
||||
currency_code: "usd",
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
return originalFn.bind(pricingModule)(...arguments)
|
||||
}
|
||||
|
||||
@@ -2133,21 +2168,14 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
|
||||
setPricingContextHook = undefined
|
||||
pricingModule.listPriceSets = originalFn
|
||||
|
||||
expect(pricingContext).toEqual(
|
||||
expect.objectContaining({
|
||||
unit_price: 200,
|
||||
region_id: cart.region_id,
|
||||
customer_id: cart.customer_id,
|
||||
currency_code: "usd",
|
||||
})
|
||||
)
|
||||
pricingModule.calculatePrices = originalFn
|
||||
|
||||
cart = await cartModuleService.retrieveCart(cart.id, {
|
||||
relations: ["items"],
|
||||
})
|
||||
|
||||
expect(calculatePricessHaveBeenCalled).toBe(true)
|
||||
|
||||
expect(cart).toEqual(
|
||||
expect.objectContaining({
|
||||
id: cart.id,
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
IStockLocationServiceNext,
|
||||
ITaxModuleService,
|
||||
} from "@medusajs/types"
|
||||
import { ContainerRegistrationKeys, Modules } from "@medusajs/utils"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
Modules,
|
||||
PromotionStatus,
|
||||
PromotionType,
|
||||
} from "@medusajs/utils"
|
||||
import {
|
||||
adminHeaders,
|
||||
createAdminUser,
|
||||
@@ -25,30 +30,25 @@ medusaIntegrationTestRunner({
|
||||
env,
|
||||
testSuite: ({ dbConnection, getContainer, api }) => {
|
||||
let appContainer
|
||||
let cartModuleService: ICartModuleService
|
||||
let regionModuleService: IRegionModuleService
|
||||
let scModuleService: ISalesChannelModuleService
|
||||
let productModule: IProductModuleService
|
||||
let pricingModule: IPricingModuleService
|
||||
let inventoryModule: IInventoryServiceNext
|
||||
let stockLocationModule: IStockLocationServiceNext
|
||||
let fulfillmentModule: IFulfillmentModuleService
|
||||
let taxModule: ITaxModuleService
|
||||
let remoteLink, remoteQuery
|
||||
let remoteLink
|
||||
|
||||
beforeAll(async () => {
|
||||
appContainer = getContainer()
|
||||
cartModuleService = appContainer.resolve(Modules.CART)
|
||||
regionModuleService = appContainer.resolve(Modules.REGION)
|
||||
scModuleService = appContainer.resolve(Modules.SALES_CHANNEL)
|
||||
productModule = appContainer.resolve(Modules.PRODUCT)
|
||||
pricingModule = appContainer.resolve(Modules.PRICING)
|
||||
inventoryModule = appContainer.resolve(Modules.INVENTORY)
|
||||
stockLocationModule = appContainer.resolve(Modules.STOCK_LOCATION)
|
||||
fulfillmentModule = appContainer.resolve(Modules.FULFILLMENT)
|
||||
taxModule = appContainer.resolve(Modules.TAX)
|
||||
remoteLink = appContainer.resolve(ContainerRegistrationKeys.REMOTE_LINK)
|
||||
remoteQuery = appContainer.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -199,7 +199,6 @@ medusaIntegrationTestRunner({
|
||||
country_code: "US",
|
||||
postal_code: "12345",
|
||||
},
|
||||
promo_codes: ["testytest"],
|
||||
items: [
|
||||
{
|
||||
variant_id: product.variants[0].id,
|
||||
@@ -386,6 +385,388 @@ medusaIntegrationTestRunner({
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("should create a draft order applying the correct promotion on the items", async () => {
|
||||
const region = await regionModuleService.createRegions({
|
||||
name: "US",
|
||||
currency_code: "usd",
|
||||
})
|
||||
|
||||
const salesChannel = await scModuleService.createSalesChannels({
|
||||
name: "Webshop",
|
||||
})
|
||||
|
||||
const location = await stockLocationModule.createStockLocations({
|
||||
name: "Warehouse",
|
||||
})
|
||||
|
||||
const [product, product_2] = await productModule.createProducts([
|
||||
{
|
||||
title: "Test product",
|
||||
variants: [
|
||||
{
|
||||
title: "Test variant",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Another product",
|
||||
variants: [
|
||||
{
|
||||
title: "Variant variable",
|
||||
manage_inventory: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const inventoryItem = await inventoryModule.createInventoryItems({
|
||||
sku: "inv-1234",
|
||||
})
|
||||
|
||||
await inventoryModule.createInventoryLevels([
|
||||
{
|
||||
inventory_item_id: inventoryItem.id,
|
||||
location_id: location.id,
|
||||
stocked_quantity: 2,
|
||||
reserved_quantity: 0,
|
||||
},
|
||||
])
|
||||
|
||||
const [priceSet, priceSet_2] = await pricingModule.createPriceSets([
|
||||
{
|
||||
prices: [
|
||||
{
|
||||
amount: 3000,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
prices: [
|
||||
{
|
||||
amount: 1000,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
/**
|
||||
* Create a promotion to test with
|
||||
*/
|
||||
const promotion = (
|
||||
await api.post(
|
||||
`/admin/promotions`,
|
||||
{
|
||||
code: "testytest",
|
||||
type: PromotionType.STANDARD,
|
||||
status: PromotionStatus.ACTIVE,
|
||||
application_method: {
|
||||
target_type: "items",
|
||||
type: "fixed",
|
||||
allocation: "each",
|
||||
currency_code: "usd",
|
||||
value: 100,
|
||||
max_quantity: 100,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "variant_id",
|
||||
operator: "in",
|
||||
values: [product.variants[0].id, product_2.variants[0].id],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
).data.promotion
|
||||
|
||||
await api.post(
|
||||
"/admin/price-preferences",
|
||||
{
|
||||
attribute: "currency_code",
|
||||
value: "usd",
|
||||
is_tax_inclusive: true,
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
await remoteLink.create([
|
||||
{
|
||||
[Modules.PRODUCT]: {
|
||||
variant_id: product.variants[0].id,
|
||||
},
|
||||
[Modules.PRICING]: {
|
||||
price_set_id: priceSet.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
[Modules.PRODUCT]: {
|
||||
variant_id: product_2.variants[0].id,
|
||||
},
|
||||
[Modules.PRICING]: {
|
||||
price_set_id: priceSet_2.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
[Modules.SALES_CHANNEL]: {
|
||||
sales_channel_id: salesChannel.id,
|
||||
},
|
||||
[Modules.STOCK_LOCATION]: {
|
||||
stock_location_id: location.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
[Modules.PRODUCT]: {
|
||||
variant_id: product.variants[0].id,
|
||||
},
|
||||
[Modules.INVENTORY]: {
|
||||
inventory_item_id: inventoryItem.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
[Modules.PRODUCT]: {
|
||||
variant_id: product_2.variants[0].id,
|
||||
},
|
||||
[Modules.INVENTORY]: {
|
||||
inventory_item_id: inventoryItem.id,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
await setupTaxStructure(taxModule)
|
||||
|
||||
const payload = {
|
||||
email: "oli@test.dk",
|
||||
region_id: region.id,
|
||||
sales_channel_id: salesChannel.id,
|
||||
currency_code: "usd",
|
||||
shipping_address: {
|
||||
first_name: "Test",
|
||||
last_name: "Test",
|
||||
address_1: "Test",
|
||||
city: "Test",
|
||||
country_code: "US",
|
||||
postal_code: "12345",
|
||||
phone: "12345",
|
||||
},
|
||||
billing_address: {
|
||||
first_name: "Test",
|
||||
last_name: "Test",
|
||||
address_1: "Test",
|
||||
city: "Test",
|
||||
country_code: "US",
|
||||
postal_code: "12345",
|
||||
},
|
||||
promo_codes: ["testytest"],
|
||||
items: [
|
||||
{
|
||||
variant_id: product.variants[0].id,
|
||||
is_discountable: true,
|
||||
quantity: 2,
|
||||
},
|
||||
{
|
||||
variant_id: product_2.variants[0].id,
|
||||
is_discountable: true,
|
||||
unit_price: 200,
|
||||
quantity: 1,
|
||||
metadata: {
|
||||
note: "reduced price",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Custom Item",
|
||||
variant_sku: "sku123",
|
||||
variant_barcode: "barcode123",
|
||||
is_discountable: true,
|
||||
unit_price: 2200,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
shipping_methods: [
|
||||
{
|
||||
name: "test-method",
|
||||
shipping_option_id: "test-option",
|
||||
amount: 100,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const response = await api.post(
|
||||
"/admin/draft-orders",
|
||||
payload,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(response.data).toEqual(
|
||||
expect.objectContaining({
|
||||
draft_order: expect.objectContaining({
|
||||
status: "draft",
|
||||
version: 1,
|
||||
summary: expect.objectContaining({
|
||||
// TODO: add summary fields
|
||||
}),
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Test product",
|
||||
subtitle: "Test variant",
|
||||
product_title: "Test product",
|
||||
product_description: null,
|
||||
product_subtitle: null,
|
||||
product_type: null,
|
||||
product_type_id: null,
|
||||
product_collection: null,
|
||||
product_handle: "test-product",
|
||||
variant_sku: null,
|
||||
variant_barcode: null,
|
||||
variant_title: "Test variant",
|
||||
variant_option_values: null,
|
||||
requires_shipping: true,
|
||||
is_discountable: true,
|
||||
is_tax_inclusive: true,
|
||||
is_custom_price: false,
|
||||
raw_compare_at_unit_price: null,
|
||||
raw_unit_price: expect.objectContaining({
|
||||
value: "3000",
|
||||
}),
|
||||
metadata: {},
|
||||
tax_lines: [
|
||||
expect.objectContaining({
|
||||
code: "US_DEF",
|
||||
provider_id: "system",
|
||||
rate: 2,
|
||||
}),
|
||||
],
|
||||
adjustments: [
|
||||
expect.objectContaining({
|
||||
amount: 200,
|
||||
code: "testytest",
|
||||
is_tax_inclusive: false,
|
||||
promotion_id: promotion.id,
|
||||
provider_id: null,
|
||||
}),
|
||||
],
|
||||
unit_price: 3000,
|
||||
quantity: 2,
|
||||
raw_quantity: expect.objectContaining({
|
||||
value: "2",
|
||||
}),
|
||||
detail: expect.objectContaining({
|
||||
raw_quantity: expect.objectContaining({
|
||||
value: "2",
|
||||
}),
|
||||
raw_fulfilled_quantity: expect.objectContaining({
|
||||
value: "0",
|
||||
}),
|
||||
raw_shipped_quantity: expect.objectContaining({
|
||||
value: "0",
|
||||
}),
|
||||
raw_return_requested_quantity: expect.objectContaining({
|
||||
value: "0",
|
||||
}),
|
||||
raw_return_received_quantity: expect.objectContaining({
|
||||
value: "0",
|
||||
}),
|
||||
raw_return_dismissed_quantity: expect.objectContaining({
|
||||
value: "0",
|
||||
}),
|
||||
raw_written_off_quantity: expect.objectContaining({
|
||||
value: "0",
|
||||
}),
|
||||
quantity: 2,
|
||||
fulfilled_quantity: 0,
|
||||
shipped_quantity: 0,
|
||||
return_requested_quantity: 0,
|
||||
return_received_quantity: 0,
|
||||
return_dismissed_quantity: 0,
|
||||
written_off_quantity: 0,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "Another product",
|
||||
subtitle: "Variant variable",
|
||||
raw_unit_price: expect.objectContaining({
|
||||
value: "200",
|
||||
}),
|
||||
metadata: {
|
||||
note: "reduced price",
|
||||
},
|
||||
unit_price: 200,
|
||||
is_tax_inclusive: true,
|
||||
quantity: 1,
|
||||
raw_quantity: expect.objectContaining({
|
||||
value: "1",
|
||||
}),
|
||||
adjustments: [
|
||||
expect.objectContaining({
|
||||
amount: 100,
|
||||
code: "testytest",
|
||||
is_tax_inclusive: false,
|
||||
promotion_id: promotion.id,
|
||||
provider_id: null,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "Custom Item",
|
||||
variant_sku: "sku123",
|
||||
variant_barcode: "barcode123",
|
||||
variant_title: null,
|
||||
is_custom_price: true,
|
||||
raw_unit_price: expect.objectContaining({
|
||||
value: "2200",
|
||||
}),
|
||||
unit_price: 2200,
|
||||
quantity: 1,
|
||||
raw_quantity: expect.objectContaining({
|
||||
value: "1",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
shipping_address: expect.objectContaining({
|
||||
last_name: "Test",
|
||||
address_1: "Test",
|
||||
city: "Test",
|
||||
country_code: "US",
|
||||
postal_code: "12345",
|
||||
phone: "12345",
|
||||
}),
|
||||
billing_address: expect.objectContaining({
|
||||
first_name: "Test",
|
||||
last_name: "Test",
|
||||
address_1: "Test",
|
||||
city: "Test",
|
||||
country_code: "US",
|
||||
postal_code: "12345",
|
||||
}),
|
||||
shipping_methods: [
|
||||
expect.objectContaining({
|
||||
name: "test-method",
|
||||
raw_amount: expect.objectContaining({
|
||||
value: "100",
|
||||
}),
|
||||
is_tax_inclusive: false,
|
||||
shipping_option_id: "test-option",
|
||||
data: null,
|
||||
tax_lines: [
|
||||
expect.objectContaining({
|
||||
code: "US_DEF",
|
||||
provider_id: "system",
|
||||
rate: 2,
|
||||
}),
|
||||
],
|
||||
adjustments: [],
|
||||
amount: 100,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("should create a draft order and apply tax by product type", async () => {
|
||||
const productType = await productModule.createProductTypes({
|
||||
value: "test_product_type",
|
||||
@@ -396,11 +777,17 @@ medusaIntegrationTestRunner({
|
||||
currency_code: "usd",
|
||||
})
|
||||
|
||||
const [taxRegion] = await taxModule.createTaxRegions([{
|
||||
country_code: "US",
|
||||
provider_id: "tp_system",
|
||||
default_tax_rate: { name: "US Default Rate", rate: 5, code: "US_DEF" },
|
||||
}])
|
||||
const [taxRegion] = await taxModule.createTaxRegions([
|
||||
{
|
||||
country_code: "US",
|
||||
provider_id: "tp_system",
|
||||
default_tax_rate: {
|
||||
name: "US Default Rate",
|
||||
rate: 5,
|
||||
code: "US_DEF",
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const [taxRate] = await taxModule.createTaxRates([
|
||||
{
|
||||
@@ -408,14 +795,15 @@ medusaIntegrationTestRunner({
|
||||
name: "US Reduced",
|
||||
rate: 3,
|
||||
code: "USREDUCE_PROD_TYPE",
|
||||
}])
|
||||
},
|
||||
])
|
||||
|
||||
await taxModule.createTaxRateRules([
|
||||
{
|
||||
reference: "product_type",
|
||||
reference_id: productType.id,
|
||||
tax_rate_id: taxRate.id,
|
||||
}
|
||||
},
|
||||
])
|
||||
|
||||
const salesChannel = await scModuleService.createSalesChannels({
|
||||
@@ -426,7 +814,6 @@ medusaIntegrationTestRunner({
|
||||
name: "Warehouse",
|
||||
})
|
||||
|
||||
|
||||
const [product] = await productModule.createProducts([
|
||||
{
|
||||
title: "Test product",
|
||||
@@ -473,6 +860,35 @@ medusaIntegrationTestRunner({
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
/**
|
||||
* Create a promotion to test with
|
||||
*/
|
||||
const promotion = (
|
||||
await api.post(
|
||||
`/admin/promotions`,
|
||||
{
|
||||
code: "testytest",
|
||||
type: PromotionType.STANDARD,
|
||||
status: PromotionStatus.ACTIVE,
|
||||
application_method: {
|
||||
target_type: "items",
|
||||
type: "fixed",
|
||||
allocation: "each",
|
||||
currency_code: "usd",
|
||||
value: 100,
|
||||
max_quantity: 100,
|
||||
target_rules: [
|
||||
{
|
||||
attribute: "variant_id",
|
||||
operator: "in",
|
||||
values: [product.variants[0].id],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
).data.promotion
|
||||
|
||||
await remoteLink.create([
|
||||
{
|
||||
@@ -546,7 +962,9 @@ medusaIntegrationTestRunner({
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(response.data.draft_order.items[0].tax_lines[0].code).toEqual("USREDUCE_PROD_TYPE")
|
||||
expect(response.data.draft_order.items[0].tax_lines[0].code).toEqual(
|
||||
"USREDUCE_PROD_TYPE"
|
||||
)
|
||||
expect(response.data.draft_order.items[0].tax_lines[0].rate).toEqual(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { createOrderChangeWorkflow } from "@medusajs/core-flows"
|
||||
import {
|
||||
createOrderChangeWorkflow,
|
||||
createOrderWorkflow,
|
||||
} from "@medusajs/core-flows"
|
||||
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
|
||||
import { IOrderModuleService, OrderDTO } from "@medusajs/types"
|
||||
import { Modules } from "@medusajs/utils"
|
||||
import {
|
||||
CreateOrderLineItemDTO,
|
||||
IOrderModuleService,
|
||||
OrderDTO,
|
||||
} from "@medusajs/types"
|
||||
import { Modules, ProductStatus } from "@medusajs/utils"
|
||||
import {
|
||||
adminHeaders,
|
||||
createAdminUser,
|
||||
@@ -26,6 +33,172 @@ medusaIntegrationTestRunner({
|
||||
await createAdminUser(dbConnection, adminHeaders, appContainer)
|
||||
})
|
||||
|
||||
describe("CreateOrderWorkflow", () => {
|
||||
it("should create an order with items quantity and no unit price and calculate prices based on the correct pricing context including quantity", async () => {
|
||||
const salesChannel = await api.post(
|
||||
"/admin/sales-channels",
|
||||
{
|
||||
name: "Test Sales Channel",
|
||||
description: "Test Sales Channel Description",
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const productData = {
|
||||
title: "Medusa T-Shirt based quantity",
|
||||
handle: "t-shirt-with-quantity-prices",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
sales_channels: [
|
||||
{
|
||||
id: salesChannel.data.sales_channel.id,
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
title: "Size",
|
||||
values: ["S"],
|
||||
},
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "S",
|
||||
sku: "SHIRT-S-BLACK-w-quantity-prices",
|
||||
options: {
|
||||
Size: "S",
|
||||
},
|
||||
manage_inventory: false,
|
||||
prices: [
|
||||
{
|
||||
amount: 1500,
|
||||
currency_code: "usd",
|
||||
min_quantity: 1,
|
||||
max_quantity: 4,
|
||||
},
|
||||
{
|
||||
amount: 1000,
|
||||
currency_code: "usd",
|
||||
min_quantity: 5,
|
||||
max_quantity: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const newProduct = await api.post(
|
||||
`/admin/products`,
|
||||
productData,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const variantId = newProduct.data.product.variants[0].id
|
||||
const salesChannelId = salesChannel.data.sales_channel.id
|
||||
const customer = (
|
||||
await api.post(
|
||||
"/admin/customers",
|
||||
{
|
||||
email: "test1@email.com",
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
).data.customer
|
||||
const region = (
|
||||
await api.post(
|
||||
"/admin/regions",
|
||||
{ name: "US", currency_code: "usd", countries: ["us"] },
|
||||
adminHeaders
|
||||
)
|
||||
).data.region
|
||||
|
||||
const { result: created } = await createOrderWorkflow(appContainer).run(
|
||||
{
|
||||
input: {
|
||||
email: customer.email,
|
||||
metadata: {
|
||||
foo: "bar",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
title: "Medusa T-Shirt based quantity",
|
||||
variant_id: variantId,
|
||||
quantity: 6,
|
||||
} as CreateOrderLineItemDTO,
|
||||
],
|
||||
sales_channel_id: salesChannelId,
|
||||
region_id: region.id,
|
||||
shipping_address: {
|
||||
first_name: "Test",
|
||||
last_name: "Test",
|
||||
address_1: "Test",
|
||||
city: "Test",
|
||||
country_code: "US",
|
||||
postal_code: "12345",
|
||||
phone: "12345",
|
||||
},
|
||||
billing_address: {
|
||||
first_name: "Test",
|
||||
last_name: "Test",
|
||||
address_1: "Test",
|
||||
city: "Test",
|
||||
country_code: "US",
|
||||
postal_code: "12345",
|
||||
},
|
||||
shipping_methods: [
|
||||
{
|
||||
name: "Test shipping method",
|
||||
amount: 10,
|
||||
data: {},
|
||||
tax_lines: [
|
||||
{
|
||||
description: "shipping Tax 1",
|
||||
tax_rate_id: "tax_usa_shipping",
|
||||
code: "code",
|
||||
rate: 10,
|
||||
},
|
||||
],
|
||||
adjustments: [
|
||||
{
|
||||
code: "VIP_10",
|
||||
amount: 1,
|
||||
description: "VIP discount",
|
||||
promotion_id: "prom_123",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
currency_code: "usd",
|
||||
customer_id: customer.id,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const order = (
|
||||
await api.get(
|
||||
"/admin/orders/" +
|
||||
created.id +
|
||||
"?fields=+raw_total,+raw_subtotal,+raw_discount_total",
|
||||
adminHeaders
|
||||
)
|
||||
).data.order
|
||||
|
||||
expect(order).toEqual(
|
||||
expect.objectContaining({
|
||||
original_item_subtotal: 6000,
|
||||
original_item_tax_total: 0,
|
||||
original_item_total: 6000,
|
||||
original_shipping_subtotal: 10,
|
||||
original_shipping_tax_total: 1,
|
||||
original_shipping_total: 11,
|
||||
original_tax_total: 1,
|
||||
original_total: 6011,
|
||||
item_subtotal: 6000,
|
||||
item_tax_total: 0,
|
||||
item_total: 6000,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Orders - Admin", () => {
|
||||
it("should get an order", async () => {
|
||||
const created = await orderModule.createOrders({
|
||||
@@ -107,7 +280,7 @@ medusaIntegrationTestRunner({
|
||||
id: expect.any(String),
|
||||
status: "pending",
|
||||
version: 1,
|
||||
display_id: 1,
|
||||
display_id: 2,
|
||||
payment_collections: [],
|
||||
payment_status: "not_paid",
|
||||
region_id: "test_region_id",
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Query } from "@medusajs/framework"
|
||||
import {
|
||||
CalculatedPriceSet,
|
||||
IPricingModuleService,
|
||||
} from "@medusajs/framework/types"
|
||||
import { MedusaError, Modules } from "@medusajs/framework/utils"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
MedusaError,
|
||||
Modules,
|
||||
} from "@medusajs/framework/utils"
|
||||
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
||||
|
||||
/**
|
||||
@@ -21,99 +26,260 @@ export interface GetVariantPriceSetsStepInput {
|
||||
context?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* The calculated price sets of the variants. The object's keys are the variant IDs.
|
||||
*/
|
||||
export interface GetVariantPriceSetsStepBulkInput {
|
||||
data: {
|
||||
variantId: string
|
||||
context?: Record<string, unknown>
|
||||
}[]
|
||||
}
|
||||
|
||||
interface VariantPriceSetData {
|
||||
id: string
|
||||
price_set?: { id: string }
|
||||
}
|
||||
|
||||
interface PriceCalculationItem {
|
||||
variantId: string
|
||||
priceSetId: string
|
||||
context?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface GetVariantPriceSetsStepOutput {
|
||||
[k: string]: CalculatedPriceSet
|
||||
}
|
||||
|
||||
export const getVariantPriceSetsStepId = "get-variant-price-sets"
|
||||
|
||||
async function fetchVariantPriceSets(
|
||||
query: Query,
|
||||
variantIds: string[]
|
||||
): Promise<VariantPriceSetData[]> {
|
||||
return (
|
||||
await query.graph({
|
||||
entity: "variant",
|
||||
fields: ["id", "price_set.id"],
|
||||
filters: { id: variantIds },
|
||||
})
|
||||
).data
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all variants have price sets and throws error for missing ones
|
||||
*/
|
||||
function validateVariantPriceSets(
|
||||
variantPriceSets: VariantPriceSetData[]
|
||||
): void {
|
||||
const notFound = variantPriceSets
|
||||
.filter((v) => !v.price_set?.id)
|
||||
.map((v) => v.id)
|
||||
|
||||
if (notFound.length) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Variants with IDs ${notFound.join(", ")} do not have a price`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified function to process variants with context grouping optimization
|
||||
* TODO: to be discussed, support batch calculation from the pricing module. Currently
|
||||
* trying to mitigate the impact by grouping items by exact same context.
|
||||
*/
|
||||
async function processVariantPriceSets(
|
||||
pricingService: IPricingModuleService,
|
||||
items: PriceCalculationItem[]
|
||||
): Promise<GetVariantPriceSetsStepOutput> {
|
||||
const result: GetVariantPriceSetsStepOutput = {}
|
||||
|
||||
// Group items by their context to minimize API calls
|
||||
const contextGroups = groupItemsByContext(items)
|
||||
|
||||
for (const [, groupItems] of contextGroups) {
|
||||
const priceSetIds = groupItems.map((item) => item.priceSetId)
|
||||
const context = groupItems[0].context // All items in group have same context
|
||||
|
||||
const calculatedPriceSets = await pricingService.calculatePrices(
|
||||
{ id: priceSetIds },
|
||||
{ context: context as Record<string, string | number> }
|
||||
)
|
||||
|
||||
// Map calculated prices back to variants
|
||||
const priceSetMap = new Map(
|
||||
calculatedPriceSets.map((priceSet) => [priceSet.id, priceSet])
|
||||
)
|
||||
|
||||
for (const item of groupItems) {
|
||||
const calculatedPriceSet = priceSetMap.get(item.priceSetId)
|
||||
if (calculatedPriceSet) {
|
||||
result[item.variantId] = calculatedPriceSet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function createContextKey(context?: Record<string, unknown>): string {
|
||||
if (!context || Object.keys(context).length === 0) {
|
||||
return "no-context"
|
||||
}
|
||||
|
||||
// Sort keys to ensure consistent grouping regardless of key order
|
||||
const sortedEntries = Object.entries(context)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}:${JSON.stringify(value)}`)
|
||||
|
||||
return sortedEntries.join("|")
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups calculation items by their context. It results in less API calls to the pricing module
|
||||
* if we are able to group multiple item with the exact same context
|
||||
*/
|
||||
function groupItemsByContext(
|
||||
items: PriceCalculationItem[]
|
||||
): Map<string, PriceCalculationItem[]> {
|
||||
const groups = new Map<string, PriceCalculationItem[]>()
|
||||
|
||||
for (const item of items) {
|
||||
const contextKey = createContextKey(item.context)
|
||||
const existingGroup = groups.get(contextKey)
|
||||
|
||||
if (existingGroup) {
|
||||
existingGroup.push(item)
|
||||
} else {
|
||||
groups.set(contextKey, [item])
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts shared context input to unified calculation items format
|
||||
*/
|
||||
function createCalculationItemsFromSharedContext(
|
||||
variantPriceSets: VariantPriceSetData[],
|
||||
sharedContext?: Record<string, unknown>
|
||||
): PriceCalculationItem[] {
|
||||
return variantPriceSets
|
||||
.filter((v) => v.price_set?.id)
|
||||
.map((v) => ({
|
||||
variantId: v.id,
|
||||
priceSetId: v.price_set!.id,
|
||||
context: sharedContext,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts individual context input to unified calculation items format
|
||||
*/
|
||||
function createCalculationItemsFromBulkData(
|
||||
bulkData: GetVariantPriceSetsStepBulkInput["data"],
|
||||
variantToPriceSetId: Map<string, string>
|
||||
): PriceCalculationItem[] {
|
||||
const calculationItems: PriceCalculationItem[] = []
|
||||
for (const item of bulkData) {
|
||||
const priceSetId = variantToPriceSetId.get(item.variantId)
|
||||
if (priceSetId) {
|
||||
calculationItems.push({
|
||||
variantId: item.variantId,
|
||||
priceSetId,
|
||||
context: item.context,
|
||||
})
|
||||
}
|
||||
}
|
||||
return calculationItems
|
||||
}
|
||||
|
||||
/**
|
||||
* This step retrieves the calculated price sets of the specified variants.
|
||||
*
|
||||
* @example
|
||||
* To retrieve a variant's price sets:
|
||||
* To retrieve variant price sets with shared context:
|
||||
*
|
||||
* ```ts
|
||||
* const data = getVariantPriceSetsStep({
|
||||
* variantIds: ["variant_123"],
|
||||
* context: { currency_code: "usd" }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* To retrieve the calculated price sets of a variant:
|
||||
* To retrieve variant price sets with individual contexts:
|
||||
*
|
||||
* ```ts
|
||||
* const data = getVariantPriceSetsStep({
|
||||
* variantIds: ["variant_123"],
|
||||
* context: {
|
||||
* currency_code: "usd"
|
||||
* }
|
||||
* data: [
|
||||
* { variantId: "variant_123", context: { currency_code: "usd" } },
|
||||
* { variantId: "variant_456", context: { currency_code: "usd" } }, // Same context - will be batched
|
||||
* { variantId: "variant_789", context: { currency_code: "eur" } }
|
||||
* ]
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const getVariantPriceSetsStep = createStep(
|
||||
getVariantPriceSetsStepId,
|
||||
async (data: GetVariantPriceSetsStepInput, { container }) => {
|
||||
if (!data.variantIds.length) {
|
||||
return new StepResponse({})
|
||||
}
|
||||
|
||||
async (
|
||||
data: GetVariantPriceSetsStepInput | GetVariantPriceSetsStepBulkInput,
|
||||
{ container }
|
||||
) => {
|
||||
const pricingModuleService = container.resolve<IPricingModuleService>(
|
||||
Modules.PRICING
|
||||
)
|
||||
const query = container.resolve<Query>(ContainerRegistrationKeys.QUERY)
|
||||
|
||||
const remoteQuery = container.resolve("remoteQuery")
|
||||
let calculationItems: PriceCalculationItem[]
|
||||
|
||||
const variantPriceSets = await remoteQuery({
|
||||
entryPoint: "variant",
|
||||
fields: ["id", "price_set.id"],
|
||||
variables: {
|
||||
id: data.variantIds,
|
||||
},
|
||||
})
|
||||
|
||||
const notFound: string[] = []
|
||||
const priceSetIds: string[] = []
|
||||
|
||||
variantPriceSets.forEach((v) => {
|
||||
if (v.price_set?.id) {
|
||||
priceSetIds.push(v.price_set.id)
|
||||
} else {
|
||||
notFound.push(v.id)
|
||||
// Handle shared context variants (original input format)
|
||||
if ("variantIds" in data) {
|
||||
if (!data.variantIds.length) {
|
||||
return new StepResponse({})
|
||||
}
|
||||
})
|
||||
|
||||
if (notFound.length) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Variants with IDs ${notFound.join(", ")} do not have a price`
|
||||
const variantPriceSets = await fetchVariantPriceSets(
|
||||
query,
|
||||
data.variantIds
|
||||
)
|
||||
|
||||
validateVariantPriceSets(variantPriceSets)
|
||||
|
||||
calculationItems = createCalculationItemsFromSharedContext(
|
||||
variantPriceSets,
|
||||
data.context
|
||||
)
|
||||
} else {
|
||||
// Handle individual context variants (bulk input format)
|
||||
const bulkData = data.data
|
||||
if (!bulkData.length) {
|
||||
return new StepResponse({})
|
||||
}
|
||||
|
||||
const variantIds = bulkData.map((item) => item.variantId)
|
||||
const variantPriceSets = await fetchVariantPriceSets(query, variantIds)
|
||||
|
||||
validateVariantPriceSets(variantPriceSets)
|
||||
|
||||
// Map variant IDs to price set IDs
|
||||
const variantToPriceSetId = new Map<string, string>()
|
||||
variantPriceSets.forEach((v) => {
|
||||
if (v.price_set?.id) {
|
||||
variantToPriceSetId.set(v.id, v.price_set.id)
|
||||
}
|
||||
})
|
||||
|
||||
calculationItems = createCalculationItemsFromBulkData(
|
||||
bulkData,
|
||||
variantToPriceSetId
|
||||
)
|
||||
}
|
||||
|
||||
const calculatedPriceSets = await pricingModuleService.calculatePrices(
|
||||
{ id: priceSetIds },
|
||||
{ context: data.context as Record<string, string | number> }
|
||||
// Use unified processing logic for both input types
|
||||
const result = await processVariantPriceSets(
|
||||
pricingModuleService,
|
||||
calculationItems
|
||||
)
|
||||
|
||||
const idToPriceSet = new Map<string, Record<string, any>>(
|
||||
calculatedPriceSets.map((p) => [p.id, p])
|
||||
)
|
||||
|
||||
const variantToCalculatedPriceSets = variantPriceSets.reduce(
|
||||
(acc, { id, price_set }) => {
|
||||
const calculatedPriceSet = idToPriceSet.get(price_set?.id)
|
||||
if (calculatedPriceSet) {
|
||||
acc[id] = calculatedPriceSet
|
||||
}
|
||||
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
return new StepResponse(
|
||||
variantToCalculatedPriceSets as GetVariantPriceSetsStepOutput
|
||||
)
|
||||
return new StepResponse(result)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -166,7 +166,6 @@ export const productVariantsFields = [
|
||||
"product.discountable",
|
||||
"product.is_giftcard",
|
||||
"product.shipping_profile.id",
|
||||
"calculated_price.*",
|
||||
"inventory_items.inventory_item_id",
|
||||
"inventory_items.required_quantity",
|
||||
"inventory_items.inventory.requires_shipping",
|
||||
|
||||
@@ -2,10 +2,12 @@ import {
|
||||
AdditionalData,
|
||||
AddToCartWorkflowInputDTO,
|
||||
ConfirmVariantInventoryWorkflowInputDTO,
|
||||
WithCalculatedPrice,
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
CartWorkflowEvents,
|
||||
deduplicate,
|
||||
filterObjectByKeys,
|
||||
isDefined,
|
||||
} from "@medusajs/framework/utils"
|
||||
import {
|
||||
@@ -19,10 +21,10 @@ import {
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
import { useQueryGraphStep } from "../../common"
|
||||
import { emitEventStep } from "../../common/steps/emit-event"
|
||||
import { useRemoteQueryStep } from "../../common/steps/use-remote-query"
|
||||
import {
|
||||
createLineItemsStep,
|
||||
getLineItemActionsStep,
|
||||
getVariantPriceSetsStep,
|
||||
updateLineItemsStep,
|
||||
} from "../steps"
|
||||
import { validateCartStep } from "../steps/validate-cart"
|
||||
@@ -36,6 +38,7 @@ import { requiredVariantFieldsForInventoryConfirmation } from "../utils/prepare-
|
||||
import {
|
||||
prepareLineItemData,
|
||||
PrepareLineItemDataInput,
|
||||
PrepareVariantLineItemInput,
|
||||
} from "../utils/prepare-line-item-data"
|
||||
import { pricingContextResult } from "../utils/schemas"
|
||||
import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory"
|
||||
@@ -148,40 +151,74 @@ export const addToCartWorkflow = createWorkflow(
|
||||
)
|
||||
|
||||
const setPricingContextResult = setPricingContext.getResult()
|
||||
const pricingContext = transform(
|
||||
{ cart, setPricingContextResult },
|
||||
(data) => {
|
||||
return {
|
||||
...data.cart,
|
||||
...(data.setPricingContextResult ? data.setPricingContextResult : {}),
|
||||
currency_code: data.cart.currency_code,
|
||||
region_id: data.cart.region_id,
|
||||
region: data.cart.region,
|
||||
customer_id: data.cart.customer_id,
|
||||
customer: data.cart.customer,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const variants = when({ variantIds }, ({ variantIds }) => {
|
||||
return !!variantIds.length
|
||||
}).then(() => {
|
||||
return useRemoteQueryStep({
|
||||
entry_point: "variants",
|
||||
const variants = when(
|
||||
"should-calculate-prices",
|
||||
{ variantIds },
|
||||
({ variantIds }) => {
|
||||
return !!variantIds.length
|
||||
}
|
||||
).then(() => {
|
||||
const pricingContext = transform(
|
||||
{ cart, items: input.items, setPricingContextResult },
|
||||
(data): { variantId: string; context: Record<string, unknown> }[] => {
|
||||
const baseContext = {
|
||||
...filterObjectByKeys(data.cart, cartFieldsForPricingContext),
|
||||
...(data.setPricingContextResult
|
||||
? data.setPricingContextResult
|
||||
: {}),
|
||||
currency_code: data.cart.currency_code,
|
||||
region_id: data.cart.region_id,
|
||||
region: data.cart.region,
|
||||
customer_id: data.cart.customer_id,
|
||||
customer: data.cart.customer,
|
||||
}
|
||||
|
||||
return data.items
|
||||
.filter((i) => i.variant_id)
|
||||
.map((item) => {
|
||||
return {
|
||||
variantId: item.variant_id!,
|
||||
context: {
|
||||
...baseContext,
|
||||
quantity: item.quantity,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const { data: variantsData } = useQueryGraphStep({
|
||||
entity: "variants",
|
||||
fields: deduplicate([
|
||||
...productVariantsFields,
|
||||
...requiredVariantFieldsForInventoryConfirmation,
|
||||
]),
|
||||
variables: {
|
||||
filters: {
|
||||
id: variantIds,
|
||||
calculated_price: {
|
||||
context: pricingContext,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
validateVariantPricesStep({ variants })
|
||||
const calculatedPriceSets = getVariantPriceSetsStep({
|
||||
data: pricingContext,
|
||||
})
|
||||
|
||||
const variants = transform(
|
||||
{ variantsData, calculatedPriceSets },
|
||||
({ variantsData, calculatedPriceSets }) => {
|
||||
return variantsData.map((variant) => {
|
||||
variant.calculated_price = calculatedPriceSets[variant.id]
|
||||
return variant
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
validateVariantPricesStep({ variants })
|
||||
|
||||
return variants as (PrepareVariantLineItemInput &
|
||||
ConfirmVariantInventoryWorkflowInputDTO["variants"][number] &
|
||||
WithCalculatedPrice)[]
|
||||
})
|
||||
|
||||
const lineItems = transform({ input, variants }, (data) => {
|
||||
const items = (data.input.items ?? []).map((item) => {
|
||||
|
||||
@@ -17,13 +17,14 @@ import {
|
||||
WorkflowData,
|
||||
WorkflowResponse,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
import { useQueryGraphStep } from "../../common"
|
||||
import { emitEventStep } from "../../common/steps/emit-event"
|
||||
import { useRemoteQueryStep } from "../../common/steps/use-remote-query"
|
||||
import {
|
||||
createCartsStep,
|
||||
findOneOrAnyRegionStep,
|
||||
findOrCreateCustomerStep,
|
||||
findSalesChannelStep,
|
||||
getVariantPriceSetsStep,
|
||||
} from "../steps"
|
||||
import { validateLineItemPricesStep } from "../steps/validate-line-item-prices"
|
||||
import { validateSalesChannelStep } from "../steps/validate-sales-channel"
|
||||
@@ -167,30 +168,62 @@ export const createCartWorkflow = createWorkflow(
|
||||
}
|
||||
)
|
||||
|
||||
const variants = when({ variantIds }, ({ variantIds }) => {
|
||||
const variants = when("has-variants", { variantIds }, ({ variantIds }) => {
|
||||
return !!variantIds.length
|
||||
}).then(() => {
|
||||
return useRemoteQueryStep({
|
||||
entry_point: "variants",
|
||||
const { data: variantsData } = useQueryGraphStep({
|
||||
entity: "variants",
|
||||
fields: deduplicate([
|
||||
...productVariantsFields,
|
||||
...requiredVariantFieldsForInventoryConfirmation,
|
||||
]),
|
||||
variables: {
|
||||
filters: {
|
||||
id: variantIds,
|
||||
calculated_price: {
|
||||
context: pricingContext,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
validateVariantPricesStep({ variants })
|
||||
const calculatedPriceContext = transform(
|
||||
{ pricingContext, items: input.items },
|
||||
(data): { variantId: string; context: Record<string, unknown> }[] => {
|
||||
const baseContext = data.pricingContext
|
||||
|
||||
return (data.items ?? [])
|
||||
.filter((i) => i.variant_id)
|
||||
.map((item) => {
|
||||
return {
|
||||
variantId: item.variant_id!,
|
||||
context: {
|
||||
...baseContext,
|
||||
quantity: item.quantity,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const calculatedPriceSets = getVariantPriceSetsStep({
|
||||
data: calculatedPriceContext,
|
||||
})
|
||||
|
||||
const variants = transform(
|
||||
{ variantsData, calculatedPriceSets },
|
||||
({ variantsData, calculatedPriceSets }) => {
|
||||
return variantsData.map((variant) => {
|
||||
variant.calculated_price = calculatedPriceSets[variant.id]
|
||||
return variant
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
validateVariantPricesStep({ variants })
|
||||
|
||||
return variants
|
||||
})
|
||||
|
||||
confirmVariantInventoryWorkflow.runAsStep({
|
||||
input: {
|
||||
sales_channel_id: salesChannel.id,
|
||||
variants,
|
||||
variants: variants!,
|
||||
items: input.items!,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
AdditionalData,
|
||||
ListShippingOptionsForCartWorkflowInput,
|
||||
} from "@medusajs/types"
|
||||
import { isDefined } from "@medusajs/framework/utils"
|
||||
import { filterObjectByKeys, isDefined } from "@medusajs/framework/utils"
|
||||
import { pricingContextResult } from "../utils/schemas"
|
||||
|
||||
export const listShippingOptionsForCartWorkflowId =
|
||||
@@ -181,7 +181,7 @@ export const listShippingOptionsForCartWorkflow = createWorkflow(
|
||||
|
||||
calculated_price: {
|
||||
context: {
|
||||
...cart,
|
||||
...filterObjectByKeys(cart, cartFieldsForPricingContext),
|
||||
...(setPricingContextResult ? setPricingContextResult : {}),
|
||||
currency_code: cart.currency_code,
|
||||
region_id: cart.region_id,
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
WorkflowData,
|
||||
WorkflowResponse,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
import { AdditionalData, CartDTO } from "@medusajs/types"
|
||||
import { useQueryGraphStep } from "../../common"
|
||||
import { useRemoteQueryStep } from "../../common/steps/use-remote-query"
|
||||
import { updateLineItemsStep } from "../steps"
|
||||
import { getVariantPriceSetsStep, updateLineItemsStep } from "../steps"
|
||||
import { validateVariantPricesStep } from "../steps/validate-variant-prices"
|
||||
import {
|
||||
cartFieldsForPricingContext,
|
||||
@@ -23,13 +25,12 @@ import {
|
||||
prepareLineItemData,
|
||||
PrepareLineItemDataInput,
|
||||
} from "../utils/prepare-line-item-data"
|
||||
import { pricingContextResult } from "../utils/schemas"
|
||||
import { refreshCartShippingMethodsWorkflow } from "./refresh-cart-shipping-methods"
|
||||
import { refreshPaymentCollectionForCartWorkflow } from "./refresh-payment-collection"
|
||||
import { updateCartPromotionsWorkflow } from "./update-cart-promotions"
|
||||
import { updateTaxLinesWorkflow } from "./update-tax-lines"
|
||||
import { upsertTaxLinesWorkflow } from "./upsert-tax-lines"
|
||||
import { AdditionalData } from "@medusajs/types"
|
||||
import { pricingContextResult } from "../utils/schemas"
|
||||
|
||||
/**
|
||||
* The details of the cart to refresh.
|
||||
@@ -142,48 +143,77 @@ export const refreshCartItemsWorkflow = createWorkflow(
|
||||
)
|
||||
const setPricingContextResult = setPricingContext.getResult()
|
||||
|
||||
when({ input }, ({ input }) => {
|
||||
when("force-refresh-calculate-prices", { input }, ({ input }) => {
|
||||
return !!input.force_refresh
|
||||
}).then(() => {
|
||||
const cart = useRemoteQueryStep({
|
||||
entry_point: "cart",
|
||||
const { data: cart } = useQueryGraphStep({
|
||||
entity: "cart",
|
||||
fields: cartFieldsForRefreshSteps,
|
||||
variables: { id: input.cart_id },
|
||||
list: false,
|
||||
filters: { id: input.cart_id },
|
||||
pagination: {
|
||||
take: 1,
|
||||
},
|
||||
options: {
|
||||
isList: false,
|
||||
},
|
||||
})
|
||||
|
||||
const variantIds = transform({ cart }, (data) => {
|
||||
const variantIds = transform({ cart }, (data: { cart: CartDTO }) => {
|
||||
return (data.cart.items ?? []).map((i) => i.variant_id).filter(Boolean)
|
||||
})
|
||||
|
||||
const cartPricingContext = transform(
|
||||
{ cart, setPricingContextResult },
|
||||
(data) => {
|
||||
return {
|
||||
...filterObjectByKeys(data.cart, cartFieldsForPricingContext),
|
||||
(data): { variantId: string; context: Record<string, unknown> }[] => {
|
||||
const cart = data.cart
|
||||
const baseContext = {
|
||||
...filterObjectByKeys(cart, cartFieldsForPricingContext),
|
||||
...(data.setPricingContextResult
|
||||
? data.setPricingContextResult
|
||||
: {}),
|
||||
currency_code: data.cart.currency_code,
|
||||
region_id: data.cart.region_id,
|
||||
region: data.cart.region,
|
||||
customer_id: data.cart.customer_id,
|
||||
customer: data.cart.customer,
|
||||
currency_code: cart.currency_code,
|
||||
region_id: cart.region_id,
|
||||
region: cart.region,
|
||||
customer_id: cart.customer_id,
|
||||
customer: cart.customer,
|
||||
}
|
||||
|
||||
return cart.items
|
||||
.filter((i) => i.variant_id)
|
||||
.map((item) => {
|
||||
return {
|
||||
variantId: item.variant_id,
|
||||
context: {
|
||||
...baseContext,
|
||||
quantity: item.quantity,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const variants = useRemoteQueryStep({
|
||||
entry_point: "variants",
|
||||
const { data: variantsData } = useQueryGraphStep({
|
||||
entity: "variants",
|
||||
fields: productVariantsFields,
|
||||
variables: {
|
||||
filters: {
|
||||
id: variantIds,
|
||||
calculated_price: {
|
||||
context: cartPricingContext,
|
||||
},
|
||||
},
|
||||
}).config({ name: "fetch-variants" })
|
||||
|
||||
const calculatedPriceSets = getVariantPriceSetsStep({
|
||||
data: cartPricingContext,
|
||||
})
|
||||
|
||||
const variants = transform(
|
||||
{ variantsData, calculatedPriceSets },
|
||||
({ variantsData, calculatedPriceSets }) => {
|
||||
return variantsData.map((variant) => {
|
||||
variant.calculated_price = calculatedPriceSets[variant.id]
|
||||
return variant
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
validateVariantPricesStep({ variants })
|
||||
|
||||
const lineItems = transform({ cart, variants }, ({ cart, variants }) => {
|
||||
@@ -244,7 +274,7 @@ export const refreshCartItemsWorkflow = createWorkflow(
|
||||
input: refreshCartInput,
|
||||
})
|
||||
|
||||
when({ input }, ({ input }) => {
|
||||
when("force-refresh-update-tax-lines", { input }, ({ input }) => {
|
||||
return !!input.force_refresh
|
||||
}).then(() => {
|
||||
updateTaxLinesWorkflow.runAsStep({
|
||||
@@ -252,7 +282,7 @@ export const refreshCartItemsWorkflow = createWorkflow(
|
||||
})
|
||||
})
|
||||
|
||||
when({ input }, ({ input }) => {
|
||||
when("force-refresh-upsert-tax-lines", { input }, ({ input }) => {
|
||||
return (
|
||||
!input.force_refresh &&
|
||||
(!!input.items?.length || !!input.shipping_methods?.length)
|
||||
|
||||
@@ -52,7 +52,7 @@ export const refreshCartShippingMethodsWorkflowId =
|
||||
export const refreshCartShippingMethodsWorkflow = createWorkflow(
|
||||
refreshCartShippingMethodsWorkflowId,
|
||||
(input: WorkflowData<RefreshCartShippingMethodsWorkflowInput>) => {
|
||||
const fetchCart = when({ input }, ({ input }) => {
|
||||
const fetchCart = when("fetch-cart", { input }, ({ input }) => {
|
||||
return !input.cart
|
||||
}).then(() => {
|
||||
return useRemoteQueryStep({
|
||||
@@ -94,9 +94,13 @@ export const refreshCartShippingMethodsWorkflow = createWorkflow(
|
||||
cart,
|
||||
})
|
||||
|
||||
when({ listShippingOptionsInput }, ({ listShippingOptionsInput }) => {
|
||||
return !!listShippingOptionsInput?.length
|
||||
}).then(() => {
|
||||
when(
|
||||
"should-prepare-shipping-methods",
|
||||
{ listShippingOptionsInput },
|
||||
({ listShippingOptionsInput }) => {
|
||||
return !!listShippingOptionsInput?.length
|
||||
}
|
||||
).then(() => {
|
||||
const shippingOptions =
|
||||
listShippingOptionsForCartWithPricingWorkflow.runAsStep({
|
||||
input: {
|
||||
|
||||
@@ -56,7 +56,7 @@ export const refreshPaymentCollectionForCartWorkflowId =
|
||||
export const refreshPaymentCollectionForCartWorkflow = createWorkflow(
|
||||
refreshPaymentCollectionForCartWorkflowId,
|
||||
(input: WorkflowData<RefreshPaymentCollectionForCartWorklowInput>) => {
|
||||
const fetchCart = when({ input }, ({ input }) => {
|
||||
const fetchCart = when("should-fetch-cart", { input }, ({ input }) => {
|
||||
return !input.cart
|
||||
}).then(() => {
|
||||
return useRemoteQueryStep({
|
||||
@@ -88,7 +88,7 @@ export const refreshPaymentCollectionForCartWorkflow = createWorkflow(
|
||||
cart,
|
||||
})
|
||||
|
||||
when({ cart }, ({ cart }) => {
|
||||
when("should-update-payment-collection", { cart }, ({ cart }) => {
|
||||
const valueIsEqual = MathBN.eq(
|
||||
cart.payment_collection?.raw_amount ?? -1,
|
||||
cart.raw_total
|
||||
|
||||
@@ -93,34 +93,33 @@ export const transferCartCustomerWorkflow = createWorkflow(
|
||||
({ cart, customer }) => cart.customer?.id !== customer.id
|
||||
)
|
||||
|
||||
when({ shouldTransfer }, ({ shouldTransfer }) => shouldTransfer).then(
|
||||
() => {
|
||||
const cartInput = transform(
|
||||
{ cart, customer },
|
||||
({ cart, customer }) => [
|
||||
{
|
||||
id: cart.id,
|
||||
customer_id: customer.id,
|
||||
email: customer.email,
|
||||
},
|
||||
]
|
||||
)
|
||||
when(
|
||||
"should-transfer-cart",
|
||||
{ shouldTransfer },
|
||||
({ shouldTransfer }) => shouldTransfer
|
||||
).then(() => {
|
||||
const cartInput = transform({ cart, customer }, ({ cart, customer }) => [
|
||||
{
|
||||
id: cart.id,
|
||||
customer_id: customer.id,
|
||||
email: customer.email,
|
||||
},
|
||||
])
|
||||
|
||||
updateCartsStep(cartInput)
|
||||
updateCartsStep(cartInput)
|
||||
|
||||
refreshCartItemsWorkflow.runAsStep({
|
||||
input: { cart_id: input.id, force_refresh: true },
|
||||
})
|
||||
refreshCartItemsWorkflow.runAsStep({
|
||||
input: { cart_id: input.id, force_refresh: true },
|
||||
})
|
||||
|
||||
emitEventStep({
|
||||
eventName: CartWorkflowEvents.CUSTOMER_TRANSFERRED,
|
||||
data: {
|
||||
id: input.id,
|
||||
customer_id: customer.customer_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
emitEventStep({
|
||||
eventName: CartWorkflowEvents.CUSTOMER_TRANSFERRED,
|
||||
data: {
|
||||
id: input.id,
|
||||
customer_id: customer.customer_id,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return new WorkflowResponse(void 0, {
|
||||
hooks: [validate],
|
||||
|
||||
@@ -75,7 +75,7 @@ export const updateCartPromotionsWorkflowId = "update-cart-promotions"
|
||||
export const updateCartPromotionsWorkflow = createWorkflow(
|
||||
updateCartPromotionsWorkflowId,
|
||||
(input: WorkflowData<UpdateCartPromotionsWorkflowInput>) => {
|
||||
const fetchCart = when({ input }, ({ input }) => {
|
||||
const fetchCart = when("should-fetch-cart", { input }, ({ input }) => {
|
||||
return !input.cart
|
||||
}).then(() => {
|
||||
return useRemoteQueryStep({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AdditionalData,
|
||||
CartDTO,
|
||||
UpdateCartWorkflowInputDTO,
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
@@ -16,11 +17,7 @@ import {
|
||||
WorkflowData,
|
||||
WorkflowResponse,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
import {
|
||||
emitEventStep,
|
||||
useQueryGraphStep,
|
||||
useRemoteQueryStep,
|
||||
} from "../../common"
|
||||
import { emitEventStep, useQueryGraphStep } from "../../common"
|
||||
import { deleteLineItemsStep } from "../../line-item"
|
||||
import {
|
||||
findOrCreateCustomerStep,
|
||||
@@ -83,9 +80,9 @@ export const updateCartWorkflowId = "update-cart"
|
||||
export const updateCartWorkflow = createWorkflow(
|
||||
updateCartWorkflowId,
|
||||
(input: WorkflowData<UpdateCartWorkflowInput>) => {
|
||||
const cartToUpdate = useRemoteQueryStep({
|
||||
entry_point: "cart",
|
||||
variables: { id: input.id },
|
||||
const { data: cartToUpdate } = useQueryGraphStep({
|
||||
entity: "cart",
|
||||
filters: { id: input.id },
|
||||
fields: [
|
||||
"id",
|
||||
"email",
|
||||
@@ -95,18 +92,26 @@ export const updateCartWorkflow = createWorkflow(
|
||||
"region.*",
|
||||
"region.countries.*",
|
||||
],
|
||||
list: false,
|
||||
throw_if_key_not_found: true,
|
||||
pagination: {
|
||||
take: 1,
|
||||
},
|
||||
options: {
|
||||
throwIfKeyNotFound: true,
|
||||
isList: false,
|
||||
},
|
||||
}).config({ name: "get-cart" })
|
||||
|
||||
const cartDataInput = transform({ input, cartToUpdate }, (data) => {
|
||||
return {
|
||||
sales_channel_id:
|
||||
data.input.sales_channel_id ?? data.cartToUpdate.sales_channel_id,
|
||||
customer_id: data.cartToUpdate.customer_id,
|
||||
email: data.input.email ?? data.cartToUpdate.email,
|
||||
const cartDataInput = transform(
|
||||
{ input, cartToUpdate },
|
||||
(data: { input: UpdateCartWorkflowInput; cartToUpdate: CartDTO }) => {
|
||||
return {
|
||||
sales_channel_id:
|
||||
data.input.sales_channel_id ?? data.cartToUpdate.sales_channel_id,
|
||||
customer_id: data.cartToUpdate.customer_id,
|
||||
email: data.input.email ?? data.cartToUpdate.email,
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const [salesChannel, customer] = parallelize(
|
||||
findSalesChannelStep({
|
||||
@@ -120,16 +125,23 @@ export const updateCartWorkflow = createWorkflow(
|
||||
|
||||
validateSalesChannelStep({ salesChannel })
|
||||
|
||||
const newRegion = when({ input }, (data) => {
|
||||
const newRegion = when("should-fetch-region", { input }, (data) => {
|
||||
return !!data.input.region_id
|
||||
}).then(() => {
|
||||
return useRemoteQueryStep({
|
||||
entry_point: "region",
|
||||
variables: { id: input.region_id },
|
||||
const { data: newRegion } = useQueryGraphStep({
|
||||
entity: "region",
|
||||
filters: { id: input.region_id },
|
||||
fields: ["id", "countries.*", "currency_code", "name"],
|
||||
list: false,
|
||||
throw_if_key_not_found: true,
|
||||
pagination: {
|
||||
take: 1,
|
||||
},
|
||||
options: {
|
||||
throwIfKeyNotFound: true,
|
||||
isList: false,
|
||||
},
|
||||
}).config({ name: "get-region" })
|
||||
|
||||
return newRegion
|
||||
})
|
||||
|
||||
const region = transform({ cartToUpdate, newRegion }, (data) => {
|
||||
@@ -239,9 +251,13 @@ export const updateCartWorkflow = createWorkflow(
|
||||
}
|
||||
)
|
||||
|
||||
when({ regionUpdated }, ({ regionUpdated }) => {
|
||||
return !!regionUpdated
|
||||
}).then(() => {
|
||||
when(
|
||||
"should-emit-region-updated",
|
||||
{ regionUpdated },
|
||||
({ regionUpdated }) => {
|
||||
return !!regionUpdated
|
||||
}
|
||||
).then(() => {
|
||||
emitEventStep({
|
||||
eventName: CartWorkflowEvents.REGION_UPDATED,
|
||||
data: { id: input.id },
|
||||
@@ -258,7 +274,7 @@ export const updateCartWorkflow = createWorkflow(
|
||||
|
||||
// In case the region is updated, we might have a new currency OR tax inclusivity setting
|
||||
// Therefore, we need to delete line items with a custom price for good measure
|
||||
when({ regionUpdated }, ({ regionUpdated }) => {
|
||||
when("should-delete-line-items", { regionUpdated }, ({ regionUpdated }) => {
|
||||
return !!regionUpdated
|
||||
}).then(() => {
|
||||
const lineItems = useQueryGraphStep({
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import {
|
||||
AdditionalData,
|
||||
CartDTO,
|
||||
CustomerDTO,
|
||||
RegionDTO,
|
||||
UpdateLineItemInCartWorkflowInputDTO,
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
CartWorkflowEvents,
|
||||
deduplicate,
|
||||
filterObjectByKeys,
|
||||
isDefined,
|
||||
MedusaError,
|
||||
QueryContext,
|
||||
} from "@medusajs/framework/utils"
|
||||
import {
|
||||
createHook,
|
||||
@@ -18,7 +23,6 @@ import {
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
import { useQueryGraphStep } from "../../common"
|
||||
import { emitEventStep } from "../../common/steps/emit-event"
|
||||
import { useRemoteQueryStep } from "../../common/steps/use-remote-query"
|
||||
import { updateLineItemsStepWithSelector } from "../../line-item/steps"
|
||||
import { validateCartStep } from "../steps/validate-cart"
|
||||
import { validateVariantPricesStep } from "../steps/validate-variant-prices"
|
||||
@@ -32,6 +36,13 @@ import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory"
|
||||
import { refreshCartItemsWorkflow } from "./refresh-cart-items"
|
||||
|
||||
const cartFields = cartFieldsForPricingContext.concat(["items.*"])
|
||||
const variantFields = productVariantsFields.concat(["calculated_price.*"])
|
||||
|
||||
interface CartQueryDTO extends Omit<CartDTO, "items"> {
|
||||
items: NonNullable<CartDTO["items"]>
|
||||
customer: CustomerDTO
|
||||
region: RegionDTO
|
||||
}
|
||||
|
||||
export const updateLineItemInCartWorkflowId = "update-line-item-in-cart"
|
||||
/**
|
||||
@@ -97,17 +108,24 @@ export const updateLineItemInCartWorkflow = createWorkflow(
|
||||
(
|
||||
input: WorkflowData<UpdateLineItemInCartWorkflowInputDTO & AdditionalData>
|
||||
) => {
|
||||
const cartQuery = useQueryGraphStep({
|
||||
const { data: cart } = useQueryGraphStep({
|
||||
entity: "cart",
|
||||
filters: { id: input.cart_id },
|
||||
fields: cartFields,
|
||||
options: { throwIfKeyNotFound: true },
|
||||
options: { throwIfKeyNotFound: true, isList: false },
|
||||
}).config({ name: "get-cart" })
|
||||
|
||||
const cart = transform({ cartQuery }, ({ cartQuery }) => cartQuery.data[0])
|
||||
const item = transform({ cart, input }, ({ cart, input }) => {
|
||||
return cart.items.find((i) => i.id === input.item_id)
|
||||
})
|
||||
const { item, variantIds } = transform(
|
||||
{ cart, input },
|
||||
(data: {
|
||||
cart: CartQueryDTO
|
||||
input: UpdateLineItemInCartWorkflowInputDTO & AdditionalData
|
||||
}) => {
|
||||
const item = data.cart.items.find((i) => i.id === data.input.item_id)!
|
||||
const variantIds = [item?.variant_id].filter(Boolean)
|
||||
return { item, variantIds }
|
||||
}
|
||||
)
|
||||
|
||||
validateCartStep({ cart })
|
||||
|
||||
@@ -116,10 +134,6 @@ export const updateLineItemInCartWorkflow = createWorkflow(
|
||||
cart,
|
||||
})
|
||||
|
||||
const variantIds = transform({ item }, ({ item }) => {
|
||||
return [item.variant_id].filter(Boolean)
|
||||
})
|
||||
|
||||
const setPricingContext = createHook(
|
||||
"setPricingContext",
|
||||
{
|
||||
@@ -134,40 +148,55 @@ export const updateLineItemInCartWorkflow = createWorkflow(
|
||||
)
|
||||
|
||||
const setPricingContextResult = setPricingContext.getResult()
|
||||
|
||||
const pricingContext = transform(
|
||||
{ cart, setPricingContextResult },
|
||||
(data) => {
|
||||
{ cart, item, update: input.update, setPricingContextResult },
|
||||
(data): Record<string, any> => {
|
||||
return {
|
||||
...data.cart,
|
||||
...filterObjectByKeys(data.cart, cartFieldsForPricingContext),
|
||||
...(data.setPricingContextResult ? data.setPricingContextResult : {}),
|
||||
quantity: data.update.quantity ?? data.item.quantity,
|
||||
currency_code: data.cart.currency_code,
|
||||
region_id: data.cart.region_id,
|
||||
region: data.cart.region,
|
||||
customer_id: data.cart.customer_id,
|
||||
customer: data.cart.customer,
|
||||
region_id: data.cart.region_id!,
|
||||
region: data.cart.region!,
|
||||
customer_id: data.cart.customer_id!,
|
||||
customer: data.cart.customer!,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const variants = when({ variantIds }, ({ variantIds }) => {
|
||||
return !!variantIds.length
|
||||
}).then(() => {
|
||||
return useRemoteQueryStep({
|
||||
entry_point: "variants",
|
||||
const variants = when(
|
||||
"should-fetch-variants",
|
||||
{ variantIds },
|
||||
({ variantIds }) => {
|
||||
return !!variantIds.length
|
||||
}
|
||||
).then(() => {
|
||||
const calculatedPriceQueryContext = transform(
|
||||
{ pricingContext },
|
||||
({ pricingContext }) => {
|
||||
return QueryContext(pricingContext)
|
||||
}
|
||||
)
|
||||
|
||||
const { data: variants } = useQueryGraphStep({
|
||||
entity: "variants",
|
||||
fields: deduplicate([
|
||||
...productVariantsFields,
|
||||
...variantFields,
|
||||
...requiredVariantFieldsForInventoryConfirmation,
|
||||
]),
|
||||
variables: {
|
||||
filters: {
|
||||
id: variantIds,
|
||||
calculated_price: {
|
||||
context: pricingContext,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
calculated_price: calculatedPriceQueryContext,
|
||||
},
|
||||
}).config({ name: "fetch-variants" })
|
||||
})
|
||||
|
||||
validateVariantPricesStep({ variants })
|
||||
validateVariantPricesStep({ variants })
|
||||
|
||||
return variants
|
||||
})
|
||||
|
||||
const items = transform({ input, item }, (data) => {
|
||||
return [
|
||||
|
||||
@@ -123,7 +123,7 @@ export const updateTaxLinesWorkflowId = "update-tax-lines"
|
||||
export const updateTaxLinesWorkflow = createWorkflow(
|
||||
updateTaxLinesWorkflowId,
|
||||
(input: WorkflowData<UpdateTaxLinesWorkflowInput>): WorkflowData<void> => {
|
||||
const fetchCart = when({ input }, ({ input }) => {
|
||||
const fetchCart = when("should-fetch-cart", { input }, ({ input }) => {
|
||||
return !input.cart
|
||||
}).then(() => {
|
||||
return useRemoteQueryStep({
|
||||
|
||||
@@ -121,7 +121,7 @@ export const upsertTaxLinesWorkflowId = "upsert-tax-lines"
|
||||
export const upsertTaxLinesWorkflow = createWorkflow(
|
||||
upsertTaxLinesWorkflowId,
|
||||
(input: WorkflowData<UpsertTaxLinesWorkflowInput>): WorkflowData<void> => {
|
||||
const fetchCart = when({ input }, ({ input }) => {
|
||||
const fetchCart = when("should-fetch-cart", { input }, ({ input }) => {
|
||||
return !input.cart
|
||||
}).then(() => {
|
||||
return useRemoteQueryStep({
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface SimpleProduct {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface FixtureEntryPoints {
|
||||
simple_product: SimpleProduct
|
||||
}
|
||||
|
||||
declare module "@medusajs/types/dist/modules-sdk/remote-query-entry-points" {
|
||||
export interface RemoteQueryEntryPoints extends FixtureEntryPoints {}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createWorkflow, WorkflowResponse } from "@medusajs/workflows-sdk"
|
||||
import { expectTypeOf } from "expect-type"
|
||||
import { FixtureEntryPoints } from "../__fixtures__/remote-query"
|
||||
import { useQueryGraphStep } from "../use-query-graph"
|
||||
import { MedusaContainer } from "@medusajs/framework"
|
||||
import { asFunction, createContainer } from "awilix"
|
||||
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
||||
|
||||
describe("useQueryGraphStep", () => {
|
||||
let container!: MedusaContainer
|
||||
|
||||
beforeAll(() => {
|
||||
container = createContainer() as unknown as MedusaContainer
|
||||
container.register(
|
||||
ContainerRegistrationKeys.QUERY,
|
||||
asFunction(() => {
|
||||
return {
|
||||
graph: () => Promise.resolve({ data: [] }),
|
||||
} as any
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should return a single data item when is_list is false", async () => {
|
||||
const workflow = createWorkflow("useQueryGraphStepTest1", (_: any) => {
|
||||
const result = useQueryGraphStep({
|
||||
entity: "simple_product",
|
||||
fields: ["*"],
|
||||
filters: {
|
||||
id: "123",
|
||||
},
|
||||
options: {
|
||||
isList: false,
|
||||
},
|
||||
})
|
||||
|
||||
return new WorkflowResponse(result)
|
||||
})
|
||||
|
||||
const result = await workflow(container).run()
|
||||
|
||||
type Result = (typeof result)["result"]
|
||||
|
||||
expectTypeOf<Result["data"]>().toEqualTypeOf<
|
||||
FixtureEntryPoints["simple_product"]
|
||||
>()
|
||||
})
|
||||
|
||||
it("should return a list of data items when is_list is true", async () => {
|
||||
const workflow = createWorkflow("useQueryGraphStepTest1", (_: any) => {
|
||||
const result = useQueryGraphStep({
|
||||
entity: "simple_product",
|
||||
fields: ["*"],
|
||||
filters: {
|
||||
id: "123",
|
||||
},
|
||||
options: {
|
||||
isList: true,
|
||||
},
|
||||
})
|
||||
|
||||
return new WorkflowResponse(result)
|
||||
})
|
||||
|
||||
const result = await workflow(container).run()
|
||||
|
||||
type Result = (typeof result)["result"]
|
||||
|
||||
expectTypeOf<Result["data"]>().toEqualTypeOf<
|
||||
FixtureEntryPoints["simple_product"][]
|
||||
>()
|
||||
})
|
||||
|
||||
it("should return a list of data items when is_list is not specified", async () => {
|
||||
const workflow = createWorkflow("useQueryGraphStepTest1", (_: any) => {
|
||||
const result = useQueryGraphStep({
|
||||
entity: "simple_product",
|
||||
fields: ["*"],
|
||||
filters: {
|
||||
id: "123",
|
||||
},
|
||||
})
|
||||
|
||||
return new WorkflowResponse(result)
|
||||
})
|
||||
|
||||
const result = await workflow(container).run()
|
||||
|
||||
type Result = (typeof result)["result"]
|
||||
|
||||
expectTypeOf<Result["data"]>().toEqualTypeOf<
|
||||
FixtureEntryPoints["simple_product"][]
|
||||
>()
|
||||
})
|
||||
})
|
||||
@@ -7,10 +7,28 @@ import {
|
||||
import { createStep, StepFunction, StepResponse } from "@medusajs/workflows-sdk"
|
||||
import { ContainerRegistrationKeys } from "@medusajs/utils"
|
||||
|
||||
export type UseQueryGraphStepInput<TEntry extends string> =
|
||||
RemoteQueryInput<TEntry> & {
|
||||
options?: RemoteJoinerOptions
|
||||
export type UseQueryGraphStepInput<
|
||||
TEntry extends string,
|
||||
TIsList extends boolean = boolean
|
||||
> = RemoteQueryInput<TEntry> & {
|
||||
options?: RemoteJoinerOptions & {
|
||||
isList?: TIsList
|
||||
}
|
||||
}
|
||||
|
||||
export type UseQueryGraphStepOutput<
|
||||
TEntry extends string,
|
||||
TIsList extends boolean = boolean
|
||||
> = ReturnType<
|
||||
StepFunction<
|
||||
any,
|
||||
true extends TIsList
|
||||
? GraphResultSet<TEntry>
|
||||
: Omit<GraphResultSet<TEntry>, "data"> & {
|
||||
data: GraphResultSet<TEntry>["data"][number]
|
||||
}
|
||||
>
|
||||
>
|
||||
|
||||
const useQueryGraphStepId = "use-query-graph-step"
|
||||
|
||||
@@ -20,9 +38,20 @@ const step = createStep(
|
||||
const query = container.resolve<RemoteQueryFunction>(
|
||||
ContainerRegistrationKeys.QUERY
|
||||
)
|
||||
|
||||
const isList = input.options?.isList ?? true
|
||||
delete input.options?.isList
|
||||
|
||||
const { options, ...queryConfig } = input
|
||||
|
||||
const result = await query.graph(queryConfig as any, options)
|
||||
|
||||
if (!isList) {
|
||||
const data = result.data?.[0]
|
||||
result.data = data
|
||||
return new StepResponse(result)
|
||||
}
|
||||
|
||||
return new StepResponse(result)
|
||||
}
|
||||
)
|
||||
@@ -100,9 +129,10 @@ const step = createStep(
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const useQueryGraphStep = <const TEntry extends string>(
|
||||
input: UseQueryGraphStepInput<TEntry>
|
||||
): ReturnType<StepFunction<any, GraphResultSet<TEntry>>> =>
|
||||
step(input as any) as unknown as ReturnType<
|
||||
StepFunction<any, GraphResultSet<TEntry>>
|
||||
>
|
||||
export const useQueryGraphStep = <
|
||||
const TEntry extends string,
|
||||
const TIsList extends boolean = boolean
|
||||
>(
|
||||
input: UseQueryGraphStepInput<TEntry, TIsList>
|
||||
): UseQueryGraphStepOutput<TEntry, TIsList> =>
|
||||
step(input as any) as unknown as UseQueryGraphStepOutput<TEntry, TIsList>
|
||||
|
||||
+3
-3
@@ -28,15 +28,15 @@ export interface RefreshDraftOrderAdjustmentsWorkflowInput {
|
||||
* The draft order to refresh the adjustments for.
|
||||
*/
|
||||
order: OrderDTO
|
||||
|
||||
// TODO: I will reintroduce this type, once I have migrated all of the order flows to fit the expected type.
|
||||
|
||||
// TODO: I will reintroduce this type, once I have migrated all of the order flows to fit the expected type.
|
||||
// Doing this in a single PR is too much work, so I'm going to do it in smaller PRs.
|
||||
//
|
||||
// order: Omit<OrderDTO, "items"> & {
|
||||
// items?: ComputeActionItemLine[]
|
||||
// promotions?: PromotionDTO[]
|
||||
// }
|
||||
|
||||
|
||||
/**
|
||||
* The promo codes to add or remove from the draft order.
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,6 @@ export const productVariantsFields = [
|
||||
"product.type.id",
|
||||
"product.collection.title",
|
||||
"product.handle",
|
||||
"calculated_price.*",
|
||||
"inventory_items.inventory_item_id",
|
||||
"inventory_items.required_quantity",
|
||||
"inventory_items.inventory.requires_shipping",
|
||||
|
||||
@@ -25,9 +25,10 @@ import {
|
||||
} from "../../cart/utils/prepare-line-item-data"
|
||||
import { pricingContextResult } from "../../cart/utils/schemas"
|
||||
import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory"
|
||||
import { useRemoteQueryStep } from "../../common"
|
||||
import { useQueryGraphStep, useRemoteQueryStep } from "../../common"
|
||||
import { createOrderLineItemsStep } from "../steps"
|
||||
import { productVariantsFields } from "../utils/fields"
|
||||
import { getVariantPriceSetsStep } from "../../cart"
|
||||
|
||||
function prepareLineItems(data) {
|
||||
const items = (data.input.items ?? []).map((item) => {
|
||||
@@ -193,30 +194,66 @@ export const addOrderLineItemsWorkflow = createWorkflow(
|
||||
}
|
||||
)
|
||||
|
||||
const variants = when({ variantIds }, ({ variantIds }) => {
|
||||
return !!variantIds.length
|
||||
}).then(() => {
|
||||
return useRemoteQueryStep({
|
||||
entry_point: "variants",
|
||||
const variants = when(
|
||||
"fetch-variants-with-calculated-price",
|
||||
{ variantIds },
|
||||
({ variantIds }) => {
|
||||
return !!variantIds.length
|
||||
}
|
||||
).then(() => {
|
||||
const { data: variantsData } = useQueryGraphStep({
|
||||
entity: "variants",
|
||||
fields: deduplicate([
|
||||
...productVariantsFields,
|
||||
...requiredVariantFieldsForInventoryConfirmation,
|
||||
]),
|
||||
variables: {
|
||||
filters: {
|
||||
id: variantIds,
|
||||
calculated_price: {
|
||||
context: pricingContext,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
validateVariantPricesStep({ variants })
|
||||
const calculatedPriceContext = transform(
|
||||
{ pricingContext, items: input.items },
|
||||
(data): { variantId: string; context: Record<string, unknown> }[] => {
|
||||
const baseContext = data.pricingContext
|
||||
|
||||
return (data.items ?? [])
|
||||
.filter((i) => i.variant_id)
|
||||
.map((item) => {
|
||||
return {
|
||||
variantId: item.variant_id!,
|
||||
context: {
|
||||
...baseContext,
|
||||
quantity: item.quantity,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const calculatedPriceSets = getVariantPriceSetsStep({
|
||||
data: calculatedPriceContext,
|
||||
})
|
||||
|
||||
const variants = transform(
|
||||
{ variantsData, calculatedPriceSets },
|
||||
({ variantsData, calculatedPriceSets }) => {
|
||||
return variantsData.map((variant) => {
|
||||
variant.calculated_price = calculatedPriceSets[variant.id]
|
||||
return variant
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
validateVariantPricesStep({ variants })
|
||||
|
||||
return variants
|
||||
})
|
||||
|
||||
confirmVariantInventoryWorkflow.runAsStep({
|
||||
input: {
|
||||
sales_channel_id: salesChannel.id,
|
||||
variants,
|
||||
variants: variants!,
|
||||
items: input.items!,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AdditionalData, CreateOrderDTO } from "@medusajs/framework/types"
|
||||
import {
|
||||
MedusaError,
|
||||
PromotionActions,
|
||||
deduplicate,
|
||||
isDefined,
|
||||
isPresent,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
transform,
|
||||
when,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
import { getVariantPriceSetsStep } from "../../cart"
|
||||
import { findOneOrAnyRegionStep } from "../../cart/steps/find-one-or-any-region"
|
||||
import { findOrCreateCustomerStep } from "../../cart/steps/find-or-create-customer"
|
||||
import { findSalesChannelStep } from "../../cart/steps/find-sales-channel"
|
||||
@@ -26,7 +28,8 @@ import {
|
||||
} from "../../cart/utils/prepare-line-item-data"
|
||||
import { pricingContextResult } from "../../cart/utils/schemas"
|
||||
import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory"
|
||||
import { useRemoteQueryStep } from "../../common"
|
||||
import { useQueryGraphStep } from "../../common"
|
||||
import { refreshDraftOrderAdjustmentsWorkflow } from "../../draft-order/workflows/refresh-draft-order-adjustments"
|
||||
import { createOrdersStep } from "../steps"
|
||||
import { productVariantsFields } from "../utils/fields"
|
||||
import { updateOrderTaxLinesWorkflow } from "./update-tax-lines"
|
||||
@@ -205,7 +208,6 @@ export const createOrderWorkflow = createWorkflow(
|
||||
)
|
||||
const setPricingContextResult = setPricingContext.getResult()
|
||||
|
||||
// TODO: This is on par with the context used in v1.*, but we can be more flexible.
|
||||
const pricingContext = transform(
|
||||
{ input, region, customerData, setPricingContextResult },
|
||||
(data) => {
|
||||
@@ -222,25 +224,133 @@ export const createOrderWorkflow = createWorkflow(
|
||||
}
|
||||
)
|
||||
|
||||
const variants = when({ variantIds }, ({ variantIds }) => {
|
||||
return !!variantIds.length
|
||||
}).then(() => {
|
||||
return useRemoteQueryStep({
|
||||
entry_point: "variants",
|
||||
/**
|
||||
* Only fetch variants with calculated prices if needed, otherwise only fetch variants without
|
||||
* calculated prices.
|
||||
*
|
||||
* We need a variant calculated price when the item is either missing a unit price or is not
|
||||
* tax inclusive.
|
||||
*/
|
||||
const { variantIdsForPriceCalculation, variantIdsWithoutCalculatedPrice } =
|
||||
transform({ input }, (data) => {
|
||||
const variantIdsForPriceCalculation: string[] = []
|
||||
const variantIdsWithoutCalculatedPrice: string[] = []
|
||||
|
||||
data.input.items?.forEach((item) => {
|
||||
if (
|
||||
item.variant_id &&
|
||||
(!isDefined(item.unit_price) || !isDefined(item.is_tax_inclusive))
|
||||
) {
|
||||
variantIdsForPriceCalculation.push(item.variant_id!)
|
||||
} else {
|
||||
variantIdsWithoutCalculatedPrice.push(item.variant_id!)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
variantIdsForPriceCalculation,
|
||||
variantIdsWithoutCalculatedPrice,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Fetch all variant for which we don't need to calculate the price.
|
||||
*/
|
||||
const { data: variantsWithoutCalculatedPrice } = useQueryGraphStep({
|
||||
entity: "variants",
|
||||
fields: deduplicate([
|
||||
...productVariantsFields,
|
||||
...requiredVariantFieldsForInventoryConfirmation,
|
||||
]),
|
||||
filters: {
|
||||
id: variantIdsWithoutCalculatedPrice,
|
||||
},
|
||||
}).config({ name: "query-variants-without-calculated-price" })
|
||||
|
||||
/**
|
||||
* Fetch all variants for which we need to calculate the price.
|
||||
*/
|
||||
const variantsWithCalculatedPrice = when(
|
||||
"fetch-variants-with-calculated-price",
|
||||
{ variantIdsForPriceCalculation },
|
||||
({ variantIdsForPriceCalculation }) => {
|
||||
return !!variantIdsForPriceCalculation.length
|
||||
}
|
||||
).then(() => {
|
||||
const calculatePricesContext = transform(
|
||||
{ items: input.items, variantIdsForPriceCalculation, pricingContext },
|
||||
(data) => {
|
||||
const baseContext = data.pricingContext
|
||||
|
||||
return data.variantIdsForPriceCalculation
|
||||
?.map((variant) => {
|
||||
// Since we retrieve the variant ids from the item, it is not possible to not find the item back from the variant id.
|
||||
const item = data.items?.find(
|
||||
(item) => item.variant_id === variant
|
||||
)!
|
||||
|
||||
return {
|
||||
variantId: variant,
|
||||
context: {
|
||||
...baseContext,
|
||||
quantity: item.quantity,
|
||||
},
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
)
|
||||
|
||||
const { data: variants } = useQueryGraphStep({
|
||||
entity: "variants",
|
||||
fields: deduplicate([
|
||||
...productVariantsFields,
|
||||
...requiredVariantFieldsForInventoryConfirmation,
|
||||
]),
|
||||
variables: {
|
||||
id: variantIds,
|
||||
calculated_price: {
|
||||
context: pricingContext,
|
||||
},
|
||||
filters: {
|
||||
id: variantIdsForPriceCalculation,
|
||||
},
|
||||
}).config({ name: "query-variants-to-calculate-prices" })
|
||||
|
||||
const calculatedPriceSets = getVariantPriceSetsStep({
|
||||
data: calculatePricesContext,
|
||||
})
|
||||
|
||||
const reconstructedVariants = transform(
|
||||
{
|
||||
variants,
|
||||
calculatedPriceSets,
|
||||
},
|
||||
(data) => {
|
||||
return data.variants.map((variant) => {
|
||||
variant.calculated_price = data.calculatedPriceSets[variant.id]
|
||||
return variant
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
validateVariantPricesStep({ variants: reconstructedVariants }).config({
|
||||
name: "validate-variants-with-calculated-price",
|
||||
})
|
||||
|
||||
return reconstructedVariants
|
||||
})
|
||||
|
||||
validateVariantPricesStep({ variants })
|
||||
/**
|
||||
* Aggregate all variants without calculated price and all variants with calculated price.
|
||||
*/
|
||||
const variants = transform(
|
||||
{
|
||||
variantsWithoutCalculatedPrice,
|
||||
variantsWithCalculatedPrice,
|
||||
},
|
||||
(data) => {
|
||||
return [
|
||||
...data.variantsWithoutCalculatedPrice,
|
||||
...(data.variantsWithCalculatedPrice ?? []),
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
confirmVariantInventoryWorkflow.runAsStep({
|
||||
input: {
|
||||
@@ -269,14 +379,63 @@ export const createOrderWorkflow = createWorkflow(
|
||||
const orders = createOrdersStep([orderToCreate])
|
||||
const order = transform({ orders }, (data) => data.orders?.[0])
|
||||
|
||||
updateOrderTaxLinesWorkflow.runAsStep({
|
||||
input: {
|
||||
order_id: order.id,
|
||||
const appliedPromoCodes: string[] = transform(
|
||||
input,
|
||||
(order) => order.promo_codes ?? []
|
||||
)
|
||||
|
||||
/**
|
||||
* TODO: Currently need the refresh because when the order module creates the order, even though
|
||||
* the totals are calculated, the order is being queried and without the totals. There is some
|
||||
* point of discussion for improvements here down the line.
|
||||
*/
|
||||
const { data: freshOrder } = useQueryGraphStep({
|
||||
entity: "orders",
|
||||
fields: [
|
||||
"shipping_address.*",
|
||||
"billing_address.*",
|
||||
"summary.*",
|
||||
"items.*",
|
||||
"credit_lines.*",
|
||||
"items.tax_lines.*",
|
||||
"items.adjustments.*",
|
||||
"shipping_methods.*",
|
||||
"shipping_methods.tax_lines.*",
|
||||
"shipping_methods.adjustments.*",
|
||||
"transactions.*",
|
||||
"currency_code",
|
||||
"items.tax_lines.*",
|
||||
"items.adjustments.*",
|
||||
"shipping_methods.tax_lines.*",
|
||||
"shipping_methods.adjustments.*",
|
||||
"total",
|
||||
"id",
|
||||
],
|
||||
filters: {
|
||||
id: order.id,
|
||||
},
|
||||
})
|
||||
options: {
|
||||
isList: false,
|
||||
},
|
||||
}).config({ name: "query-fresh-order" })
|
||||
|
||||
parallelize(
|
||||
updateOrderTaxLinesWorkflow.runAsStep({
|
||||
input: {
|
||||
order_id: order.id,
|
||||
},
|
||||
}),
|
||||
refreshDraftOrderAdjustmentsWorkflow.runAsStep({
|
||||
input: {
|
||||
order: freshOrder,
|
||||
promo_codes: appliedPromoCodes,
|
||||
action: PromotionActions.REPLACE,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const orderCreated = createHook("orderCreated", {
|
||||
order,
|
||||
order: freshOrder,
|
||||
additional_data: input.additional_data,
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user