feat(medusa): integrate pricing module to core (#5304)

* add pricing integraiton feature flag

* init

* first endpoint

* cleanup

* remove console.logs

* refactor to util and implement across endpoints

* add changeset

* rename variables

* remove mistype

* feat(medusa): move price module integration to pricing service (#5322)

* initial changes

* chore: make product service always internal for pricing module

* add notes

---------

Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>

* nit

* cleanup

* update to object querying

* update cart integration test

* remove uppercase currency_code

* nit

* Feat/admin product pricing module reads (#5354)

* initial changes to list prices for admin

* working price module implementation of list prices

* nit

* variant pricing

* redo integration test changes

* cleanup

* cleanup

* fix unit tests

* [wip] Core <> Pricing - price updates  (#5364)

* chore: update medusa-app

* wip

* get links and modules working with migration

* wip

* chore: make test pass

* Feat/rule type utils (#5371)

* initial rule type utils

* update migration script

* chore: cleanup

* ensure prices are always decorated

* chore: use seed instead

* chore: fix oas conflict

* region id add to admin price read!

---------

Co-authored-by: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com>
Co-authored-by: Philip Korsholm <philip.korsholm@hotmail.com>

* pr feedback

* create remoteQueryFunction type

* fix merge

* fix loaders issue

* Feat(medusa, types, pricing): pricing module migration script (#5409)

* add migration script for money amounts in pricing module

* add changeset

* rename file

* cleanup imports

* update changeset

* add check for pricing module and ff

* feat(medusa,workflows,types): update prices on product and variant update (#5412)

* wip

* chore: update product prices through workflow

* chore: cleanup

* chore: update product handler updates prices for variants

* chore: handle reverts

* chore: address pr comments

* chore: scope workflow handlers to flag handlers

* chore: update return

* chore: update db url

* chore: remove migration

* chore: increase jest timeout

* Feat(medusa): update migration and initDb to run link-migrations (#5437)

* initial

* loader update

* more progress on loaders

* update integration tests and remote-query loader

* remove helper

* migrate isolated modules

* fix test

* fix integration test

* update with pr feedback

* unregister medusa-app

* re-register medusaApp

* fix featureflag

* set timeout

* set timeout

* conditionally run link-module migrations

* pr feedback 1

* add driver options for db

* throw if link is not defined in migration script

* pass config module directly

* include container in migrate command

* chore: increase timeout

* rm redis from api integration tests to test

* chore: temporarily skip tests

* chore: undo skips + add timeout for workflow tests

* chore: increase timeout for order edits

* re-add redis

* include final resolution

* add sharedcontainer to medusaapp loader

* chore: move migration under run command

* try removing redis_url from api tests

* chore: cleanup server on process exit

* chore: clear container on exit

* chore: adjustments

* chore: remove consoles

* chore: close express app on finish

* chore: destroy pg connection on shutdown

* chore: skip

* chore: unskip test

* chore: cleanup container pg connection

* chore: skip

---------

Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
This commit is contained in:
Philip Korsholm
2023-10-30 14:42:17 +01:00
committed by GitHub
co-authored by Riqwan Thamir Philip Korsholm
parent b69f182571
commit 148f537b47
84 changed files with 2702 additions and 284 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@medusajs/medusa": patch
"@medusajs/types": patch
"@medusajs/pricing": patch
---
feat(medusa): add migration script for pricing module
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
feat(medusa): integrate pricing into reads
@@ -21,7 +21,7 @@ const {
} = require("../../../../factories")
const setupServer = require("../../../../environment-helpers/setup-server")
jest.setTimeout(30000)
jest.setTimeout(100000)
const adminHeaders = {
headers: {
@@ -20,6 +20,7 @@ const {
simpleDiscountFactory,
simpleSalesChannelFactory,
simpleRegionFactory,
simplePriceListFactory,
} = require("../../../factories")
const { DiscountRuleType, AllocationType } = require("@medusajs/medusa/dist")
const { IdMap } = require("medusa-test-utils")
@@ -117,6 +118,39 @@ describe("/admin/products", () => {
)
})
it("should return prices not in price list for list product endpoint", async () => {
const api = useApi()
await simplePriceListFactory(dbConnection, {
prices: [
{
variant_id: "test-variant",
amount: 100,
currency_code: "usd",
},
],
})
const res = await api.get("/admin/products?id=test-product", adminHeaders)
const prices = res.data.products[0].variants.map((v) => v.prices).flat()
expect(res.status).toEqual(200)
expect(res.data.products).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "test-product",
status: "draft",
}),
])
)
expect(prices).toEqual(
expect.not.arrayContaining([
expect.objectContaining({ price_list_id: expect.any(String) }),
])
)
})
it("returns a list of products where status is proposed", async () => {
const api = useApi()
+1 -1
View File
@@ -11,7 +11,7 @@ const enableResponseCompression =
module.exports = {
plugins: [],
projectConfig: {
redis_url: redisUrl,
// redis_url: redisUrl,
database_url: `postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}/${DB_NAME}`,
database_type: "postgres",
jwt_secret: "test",
+6 -1
View File
@@ -1,11 +1,16 @@
const path = require("path")
const express = require("express")
const getPort = require("get-port")
const { isObject } = require("@medusajs/utils")
module.exports = {
bootstrapApp: async ({ cwd } = {}) => {
bootstrapApp: async ({ cwd, env = {} } = {}) => {
const app = express()
if (isObject(env)) {
Object.entries(env).forEach(([k, v]) => (process.env[k] = v))
}
const loaders = require("@medusajs/medusa/dist/loaders").default
const { container, dbConnection } = await loaders({
@@ -1,10 +1,22 @@
const path = require("path")
const { spawn } = require("child_process")
const { setPort } = require("./use-api")
const { setPort, useExpressServer } = require("./use-api")
const { setContainer } = require("./use-container")
module.exports = ({ cwd, redisUrl, uploadDir, verbose, env }) => {
module.exports = ({
cwd,
redisUrl,
uploadDir,
verbose,
env,
bootstrapApp = false,
}) => {
const serverPath = path.join(__dirname, "test-server.js")
if (bootstrapApp) {
require(serverPath)
}
// in order to prevent conflicts in redis, use a different db for each worker
// same fix as for databases (works with up to 15)
// redis dbs are 0-indexed and jest worker ids are indexed from 1
@@ -44,5 +56,15 @@ module.exports = ({ cwd, redisUrl, uploadDir, verbose, env }) => {
setPort(port)
resolve(medusaProcess)
})
medusaProcess.on("exit", () => {
const expressServer = useExpressServer()
setContainer(null)
if (expressServer) {
expressServer.close()
}
})
})
}
@@ -1,11 +1,18 @@
const { bootstrapApp } = require("./bootstrap-app")
const { setContainer } = require("./use-container")
const { setPort, setExpressServer } = require("./use-api")
const setup = async () => {
const { app, port } = await bootstrapApp()
const { app, port, container } = await bootstrapApp()
app.listen(port, (err) => {
setContainer(container)
const expressServer = app.listen(port, (err) => {
setPort(port)
process.send(port)
})
setExpressServer(expressServer)
}
setup()
@@ -3,10 +3,15 @@ const axios = require("axios").default
const ServerTestUtil = {
port_: null,
client_: null,
expressServer_: null,
setPort: function (port) {
this.client_ = axios.create({ baseURL: `http://localhost:${port}` })
},
setExpressServer: function (expressServer) {
this.expressServer_ = expressServer
},
}
const instance = ServerTestUtil
@@ -15,7 +20,13 @@ module.exports = {
setPort: function (port) {
instance.setPort(port)
},
setExpressServer: function (expressServer) {
instance.setExpressServer(expressServer)
},
useApi: function () {
return instance.client_
},
useExpressServer: function () {
return instance.expressServer_
},
}
@@ -0,0 +1,23 @@
const path = require("path")
const express = require("express")
const getPort = require("get-port")
const { isObject } = require("@medusajs/utils")
const AppUtils = {
container_: null,
setContainer: function (container) {
this.container_ = container
},
}
const instance = AppUtils
module.exports = {
setContainer: (container) => {
instance.setContainer(container)
},
getContainer: () => {
return instance.container_
},
}
@@ -1,9 +1,12 @@
const path = require("path")
const { getConfigFile } = require("medusa-core-utils")
const { isObject, createMedusaContainer } = require("@medusajs/utils")
const { dropDatabase } = require("pg-god")
const { DataSource } = require("typeorm")
const dbFactory = require("./use-template-db")
const { getContainer } = require("./use-container")
const { ContainerRegistrationKeys } = require("@medusajs/utils")
const DB_HOST = process.env.DB_HOST
const DB_USERNAME = process.env.DB_USERNAME
@@ -29,20 +32,23 @@ const keepTables = [
const DbTestUtil = {
db_: null,
pgConnection_: null,
setDb: function (dataSource) {
this.db_ = dataSource
},
setPgConnection: function (pgConnection) {
this.pgConnection_ = pgConnection
},
clear: async function () {
this.db_.synchronize(true)
},
teardown: async function ({ forceDelete } = {}) {
forceDelete = forceDelete || []
const entities = this.db_.entityMetadatas
const manager = this.db_.manager
await manager.query(`SET session_replication_role = 'replica';`)
@@ -63,7 +69,15 @@ const DbTestUtil = {
},
shutdown: async function () {
const container = getContainer()
const containerPgConnection = container.resolve(
ContainerRegistrationKeys.PG_CONNECTION
)
await this.db_.destroy()
await this.pgConnection_?.context?.destroy()
await containerPgConnection?.context?.destroy()
return await dropDatabase({ DB_NAME }, pgGodCredentials)
},
}
@@ -71,14 +85,17 @@ const DbTestUtil = {
const instance = DbTestUtil
module.exports = {
initDb: async function ({ cwd, database_extra }) {
initDb: async function ({ cwd, database_extra, env }) {
if (isObject(env)) {
Object.entries(env).forEach(([k, v]) => (process.env[k] = v))
}
const { configModule } = getConfigFile(cwd, `medusa-config`)
const { featureFlags } = configModule
const featureFlagsLoader =
require("@medusajs/medusa/dist/loaders/feature-flags").default
const featureFlagsRouter = featureFlagsLoader({ featureFlags })
const featureFlagRouter = featureFlagsLoader(configModule)
const modelsLoader = require("@medusajs/medusa/dist/loaders/models").default
const entities = modelsLoader({}, { register: false })
@@ -104,10 +121,10 @@ module.exports = {
} = require("@medusajs/medusa/dist/commands/utils/get-migrations")
const { migrations: moduleMigrations, models: moduleModels } =
getModuleSharedResources(configModule, featureFlagsRouter)
getModuleSharedResources(configModule, featureFlagRouter)
const enabledMigrations = getEnabledMigrations([migrationDir], (flag) =>
featureFlagsRouter.isFeatureEnabled(flag)
featureFlagRouter.isFeatureEnabled(flag)
)
const enabledEntities = entities.filter(
@@ -128,6 +145,40 @@ module.exports = {
await dbDataSource.runMigrations()
instance.setDb(dbDataSource)
const IsolateProductDomainFeatureFlag =
require("@medusajs/medusa/dist/loaders/feature-flags/isolate-product-domain").default
const IsolatePricingDomainFeatureFlag =
require("@medusajs/medusa/dist/loaders/feature-flags/isolate-pricing-domain").default
if (
featureFlagRouter.isFeatureEnabled(IsolateProductDomainFeatureFlag.key) ||
featureFlagRouter.isFeatureEnabled(IsolatePricingDomainFeatureFlag.key)
) {
const pgConnectionLoader =
require("@medusajs/medusa/dist/loaders/pg-connection").default
const medusaAppLoader =
require("@medusajs/medusa/dist/loaders/medusa-app").default
const container = createMedusaContainer()
const pgConnection = await pgConnectionLoader({ configModule, container })
instance.setPgConnection(pgConnection)
const { runMigrations } = await medusaAppLoader(
{ configModule, container },
{ registerInContainer: false }
)
const options = {
database: {
clientUrl: DB_URL,
},
}
await runMigrations(options)
}
return dbDataSource
},
useDb: function () {
@@ -1,12 +1,12 @@
import { MoneyAmount, PriceList, Region } from "@medusajs/medusa"
import path from "path"
import { ProductVariantMoneyAmount } from "@medusajs/medusa"
import { bootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import setupServer from "../../../../environment-helpers/setup-server"
import { setPort, useApi } from "../../../../environment-helpers/use-api"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import { simpleProductFactory } from "../../../../factories"
import { ProductVariantMoneyAmount } from "@medusajs/medusa"
jest.setTimeout(30000)
@@ -23,7 +23,7 @@ describe("/store/carts", () => {
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: true })
medusaProcess = await setupServer({ cwd })
const { app, port } = await bootstrapApp({ cwd })
setPort(port)
express = app.listen(port, () => {
@@ -134,7 +134,7 @@ describe("/store/carts", () => {
await dbConnection.manager.save(ma_sale_1)
await dbConnection.manager.insert(ProductVariantMoneyAmount, {
id: 'pvma-test',
id: "pvma-test",
variant_id: prodSale.variants[0].id,
money_amount_id: ma_sale_1.id,
})
@@ -32,7 +32,7 @@ describe("Inventory Items endpoints", () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd })
const { container, app, port } = await bootstrapApp({ cwd, verbose: true })
const { container, app, port } = await bootstrapApp({ cwd })
appContainer = container
// Set feature flag
@@ -0,0 +1,139 @@
import { setPort, useApi } from "../../../environment-helpers/use-api"
import { initDb, useDb } from "../../../environment-helpers/use-db"
import { simpleCartFactory, simpleRegionFactory } from "../../../factories"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { AxiosInstance } from "axios"
import path from "path"
import { bootstrapApp } from "../../../environment-helpers/bootstrap-app"
import adminSeeder from "../../../helpers/admin-seeder"
jest.setTimeout(5000000)
const DB_HOST = process.env.DB_HOST
const DB_USERNAME = process.env.DB_USERNAME
const DB_PASSWORD = process.env.DB_PASSWORD
const DB_NAME = process.env.DB_TEMP_NAME
const DB_URL = `postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}/${DB_NAME}`
const adminHeaders = {
headers: {
"x-medusa-access-token": "test_token",
},
}
const env = {
MEDUSA_FF_ISOLATE_PRICING_DOMAIN: true,
MEDUSA_FF_ISOLATE_PRODUCT_DOMAIN: true,
}
describe("Link Modules", () => {
let medusaContainer
let dbConnection
let express
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
const { container, app, port } = await bootstrapApp({ cwd, env })
medusaContainer = container
setPort(port)
express = app.listen(port)
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
express.close()
})
beforeEach(async () => {
await adminSeeder(dbConnection)
await simpleRegionFactory(dbConnection, {
id: "region-1",
currency_code: "usd",
})
})
describe("get product price", () => {
let ruleType
let priceSet
let productId
const cartId = "test-cart"
beforeEach(async () => {
const pricingModuleService = medusaContainer.resolve(
ModuleRegistrationName.PRICING
)
const api = useApi()! as AxiosInstance
await simpleCartFactory(dbConnection, { id: cartId, region: "region-1" })
const payload = {
title: "Test",
description: "test-product-description",
images: ["test-image.png", "test-image-2.png"],
variants: [
{
title: "Test variant",
prices: [],
options: [],
},
],
}
const response = await api.post("/admin/products", payload, adminHeaders)
productId = response.data.product.id
const variant = response.data.product.variants[0]
ruleType = await pricingModuleService.createRuleTypes([
{ name: "region_id", rule_attribute: "region_id" },
])
priceSet = await pricingModuleService.create({
rules: [{ rule_attribute: "region_id" }],
prices: [
{
amount: 1000,
currency_code: "usd",
rules: { region_id: "region-1" },
},
{
amount: 900,
currency_code: "usd",
rules: { region_id: "region-2" },
},
],
})
const remoteLink = medusaContainer.resolve("remoteLink") as any
await remoteLink.create({
productService: {
variant_id: variant.id,
},
pricingService: {
price_set_id: priceSet.id,
},
})
})
it("Should get prices declared in pricing module", async () => {
const api = useApi()! as AxiosInstance
const response = await api.get(
`/store/products/${productId}?cart_id=${cartId}`
)
expect(response.data.product.variants[0].prices).toEqual([
expect.objectContaining({
amount: 1000,
currency_code: "usd",
}),
])
})
})
})
@@ -0,0 +1,115 @@
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import { Region } from "@medusajs/medusa"
import { AxiosInstance } from "axios"
import path from "path"
import setupServer from "../../../../environment-helpers/setup-server"
import { useApi } from "../../../../environment-helpers/use-api"
import { getContainer } from "../../../../environment-helpers/use-container"
import adminSeeder from "../../../../helpers/admin-seeder"
import { createDefaultRuleTypes } from "../../../helpers/create-default-rule-types"
jest.setTimeout(50000)
const adminHeaders = {
headers: {
"x-medusa-access-token": "test_token",
},
}
const env = {
MEDUSA_FF_ISOLATE_PRICING_DOMAIN: true,
MEDUSA_FF_ISOLATE_PRODUCT_DOMAIN: true,
}
describe("[Product & Pricing Module] POST /admin/products", () => {
let dbConnection
let appContainer
let medusaProcess
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
medusaProcess = await setupServer({ cwd, env, bootstrapApp: true } as any)
appContainer = getContainer()
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
beforeEach(async () => {
const manager = dbConnection.manager
await adminSeeder(dbConnection)
await createDefaultRuleTypes(appContainer)
await manager.insert(Region, {
id: "test-region",
name: "Test Region",
currency_code: "usd",
tax_rate: 0,
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should create prices with region_id and currency_code context", async () => {
const api = useApi()! as AxiosInstance
const data = {
title: "test product",
options: [{ title: "test-option" }],
variants: [
{
title: "test variant",
prices: [
{
amount: 66600,
region_id: "test-region",
},
{
amount: 55500,
currency_code: "usd",
},
],
options: [{ value: "test-option" }],
},
],
}
let response = await api.post(
"/admin/products?relations=variants.prices",
data,
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data).toEqual({
product: expect.objectContaining({
id: expect.any(String),
title: "test product",
variants: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
title: "test variant",
prices: expect.arrayContaining([
expect.objectContaining({
amount: 66600,
currency_code: "usd",
}),
expect.objectContaining({
amount: 55500,
currency_code: "usd",
}),
]),
}),
]),
}),
})
})
})
@@ -23,7 +23,6 @@ const adminHeaders = {
}
describe("/admin/products", () => {
let medusaProcess
let dbConnection
let express
let medusaContainer
@@ -44,7 +43,7 @@ describe("/admin/products", () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
express.close()
})
it("Should have loaded the product module", function () {
@@ -0,0 +1,253 @@
import setupServer from "../../../../environment-helpers/setup-server"
import { useApi } from "../../../../environment-helpers/use-api"
import { getContainer } from "../../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import {
simpleProductFactory,
simpleRegionFactory,
} from "../../../../factories"
import path from "path"
import adminSeeder from "../../../../helpers/admin-seeder"
import { createDefaultRuleTypes } from "../../../helpers/create-default-rule-types"
import { createVariantPriceSet } from "../../../helpers/create-variant-price-set"
jest.setTimeout(50000)
const adminHeaders = {
headers: {
"x-medusa-access-token": "test_token",
},
}
const env = {
MEDUSA_FF_ISOLATE_PRICING_DOMAIN: true,
MEDUSA_FF_ISOLATE_PRODUCT_DOMAIN: true,
}
describe("[Product & Pricing Module] POST /admin/products/:id/variants/:id", () => {
let dbConnection
let appContainer
let medusaProcess
let product
let variant
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
medusaProcess = await setupServer({ cwd, env, bootstrapApp: true } as any)
appContainer = getContainer()
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
beforeEach(async () => {
await adminSeeder(dbConnection)
await createDefaultRuleTypes(appContainer)
await simpleRegionFactory(dbConnection, {
id: "test-region",
name: "Test Region",
currency_code: "usd",
tax_rate: 0,
})
product = await simpleProductFactory(dbConnection, {
id: "test-product-with-variant",
variants: [
{
options: [{ option_id: "test-product-option-1", value: "test" }],
},
],
options: [
{
id: "test-product-option-1",
title: "Test option 1",
},
],
})
variant = product.variants[0]
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should create product variant price sets and prices", async () => {
const api = useApi()
const data = {
title: "test variant update",
prices: [
{
amount: 66600,
region_id: "test-region",
},
{
amount: 55500,
currency_code: "usd",
region_id: null,
},
],
}
let response = await api.post(
`/admin/products/${product.id}/variants/${variant.id}`,
data,
adminHeaders
)
response = await api.get(`/admin/products/${product.id}`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.product).toEqual(
expect.objectContaining({
id: expect.any(String),
variants: expect.arrayContaining([
expect.objectContaining({
id: variant.id,
title: "test variant update",
prices: expect.arrayContaining([
expect.objectContaining({
amount: 66600,
currency_code: "usd",
region_id: "test-region",
}),
expect.objectContaining({
amount: 55500,
currency_code: "usd",
}),
]),
}),
]),
})
)
})
it("should update money amounts if money amount id is present in prices", async () => {
const priceSet = await createVariantPriceSet({
container: appContainer,
variantId: variant.id,
prices: [
{
amount: 3000,
currency_code: "usd",
rules: {},
},
],
})
const moneyAmountToUpdate = priceSet.money_amounts?.[0]
const api = useApi()
const data = {
title: "test variant update",
prices: [
{
amount: 66600,
region_id: "test-region",
},
{
id: moneyAmountToUpdate?.id,
amount: 2222,
currency_code: "usd",
region_id: null,
},
],
}
let response = await api.post(
`/admin/products/${product.id}/variants/${variant.id}`,
data,
adminHeaders
)
response = await api.get(`/admin/products/${product.id}`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.product).toEqual(
expect.objectContaining({
id: expect.any(String),
variants: expect.arrayContaining([
expect.objectContaining({
id: variant.id,
title: "test variant update",
prices: expect.arrayContaining([
expect.objectContaining({
amount: 66600,
currency_code: "usd",
region_id: "test-region",
}),
expect.objectContaining({
id: moneyAmountToUpdate?.id,
amount: 2222,
currency_code: "usd",
}),
]),
}),
]),
})
)
})
it("should add prices if price set is already present", async () => {
await createVariantPriceSet({
container: appContainer,
variantId: variant.id,
prices: [],
})
const api = useApi()
const data = {
title: "test variant update",
prices: [
{
amount: 123,
region_id: "test-region",
},
{
amount: 456,
currency_code: "usd",
region_id: null,
},
],
}
let response = await api.post(
`/admin/products/${product.id}/variants/${variant.id}`,
data,
adminHeaders
)
response = await api.get(`/admin/products/${product.id}`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.product).toEqual(
expect.objectContaining({
id: expect.any(String),
variants: expect.arrayContaining([
expect.objectContaining({
id: variant.id,
title: "test variant update",
prices: expect.arrayContaining([
expect.objectContaining({
amount: 123,
currency_code: "usd",
region_id: "test-region",
}),
expect.objectContaining({
amount: 456,
currency_code: "usd",
}),
]),
}),
]),
})
)
})
})
@@ -0,0 +1,283 @@
import setupServer from "../../../../environment-helpers/setup-server"
import { useApi } from "../../../../environment-helpers/use-api"
import { getContainer } from "../../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import { simpleProductFactory } from "../../../../factories"
import { Region } from "@medusajs/medusa"
import { AxiosInstance } from "axios"
import path from "path"
import adminSeeder from "../../../../helpers/admin-seeder"
import { createDefaultRuleTypes } from "../../../helpers/create-default-rule-types"
import { createVariantPriceSet } from "../../../helpers/create-variant-price-set"
jest.setTimeout(50000)
const adminHeaders = {
headers: {
"x-medusa-access-token": "test_token",
},
}
const env = {
MEDUSA_FF_ISOLATE_PRICING_DOMAIN: true,
MEDUSA_FF_ISOLATE_PRODUCT_DOMAIN: true,
}
describe.skip("[Product & Pricing Module] POST /admin/products/:id", () => {
let dbConnection
let appContainer
let medusaProcess
let product
let variant
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
medusaProcess = await setupServer({ cwd, env, bootstrapApp: true } as any)
appContainer = getContainer()
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
beforeEach(async () => {
const manager = dbConnection.manager
await adminSeeder(dbConnection)
await createDefaultRuleTypes(appContainer)
await manager.insert(Region, {
id: "test-region",
name: "Test Region",
currency_code: "usd",
tax_rate: 0,
})
product = await simpleProductFactory(dbConnection, {
id: "test-product-with-variant",
variants: [
{
options: [{ option_id: "test-product-option-1", value: "test" }],
},
],
options: [
{
id: "test-product-option-1",
title: "Test option 1",
},
],
})
variant = product.variants[0]
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should update product variant price sets and prices", async () => {
const api = useApi()
const data = {
title: "test product update",
variants: [
{
id: variant.id,
title: "test variant update",
prices: [
{
amount: 66600,
region_id: "test-region",
},
{
amount: 55500,
currency_code: "usd",
region_id: null,
},
],
},
],
}
let response = await api.post(
`/admin/products/${product.id}`,
data,
adminHeaders
)
response = await api.get(`/admin/products/${product.id}`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.product).toEqual(
expect.objectContaining({
id: expect.any(String),
variants: expect.arrayContaining([
expect.objectContaining({
id: variant.id,
title: "test variant update",
prices: expect.arrayContaining([
expect.objectContaining({
amount: 66600,
currency_code: "usd",
region_id: "test-region",
}),
expect.objectContaining({
amount: 55500,
currency_code: "usd",
}),
]),
}),
]),
})
)
})
it("should update money amounts if money amount id is present in prices", async () => {
const priceSet = await createVariantPriceSet({
container: appContainer,
variantId: variant.id,
prices: [
{
amount: 3000,
currency_code: "usd",
rules: {},
},
],
})
const moneyAmountToUpdate = priceSet.money_amounts?.[0]
const api = useApi()
const data = {
title: "test product update",
variants: [
{
id: variant.id,
title: "test variant update",
prices: [
{
amount: 66600,
region_id: "test-region",
},
{
id: moneyAmountToUpdate?.id,
amount: 2222,
currency_code: "usd",
region_id: null,
},
],
},
],
}
let response = await api.post(
`/admin/products/${product.id}`,
data,
adminHeaders
)
response = await api.get(`/admin/products/${product.id}`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.product).toEqual(
expect.objectContaining({
id: expect.any(String),
variants: expect.arrayContaining([
expect.objectContaining({
id: variant.id,
title: "test variant update",
prices: expect.arrayContaining([
expect.objectContaining({
amount: 66600,
currency_code: "usd",
region_id: "test-region",
}),
expect.objectContaining({
id: moneyAmountToUpdate?.id,
amount: 2222,
currency_code: "usd",
}),
]),
}),
]),
})
)
})
it("should add prices if price set is already present", async () => {
const remoteLink = appContainer.resolve("remoteLink")
const pricingModuleService = appContainer.resolve("pricingModuleService")
const priceSet = await pricingModuleService.create({
rules: [{ rule_attribute: "region_id" }],
prices: [],
})
await remoteLink.create({
productService: {
variant_id: variant.id,
},
pricingService: {
price_set_id: priceSet.id,
},
})
const api = useApi()! as AxiosInstance
const data = {
title: "test product update",
variants: [
{
id: variant.id,
title: "test variant update",
prices: [
{
amount: 123,
region_id: "test-region",
},
{
amount: 456,
currency_code: "usd",
region_id: null,
},
],
},
],
}
let response = await api.post(
`/admin/products/${product.id}`,
data,
adminHeaders
)
response = await api.get(`/admin/products/${product.id}`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.product).toEqual(
expect.objectContaining({
id: expect.any(String),
variants: expect.arrayContaining([
expect.objectContaining({
id: variant.id,
title: "test variant update",
prices: expect.arrayContaining([
expect.objectContaining({
amount: 123,
currency_code: "usd",
region_id: "test-region",
}),
expect.objectContaining({
amount: 456,
currency_code: "usd",
}),
]),
}),
]),
})
)
})
})
@@ -1,16 +1,18 @@
import { WorkflowTypes } from "@medusajs/types"
import {
Handlers,
pipe,
updateProducts,
UpdateProductsActions,
} from "@medusajs/workflows"
import { WorkflowTypes } from "@medusajs/types"
import path from "path"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import { bootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import { simpleProductFactory } from "../../../../factories"
jest.setTimeout(30000)
describe("UpdateProduct workflow", function () {
let medusaProcess
let dbConnection
@@ -0,0 +1,14 @@
import { IPricingModuleService } from "@medusajs/types"
export const createDefaultRuleTypes = async (container) => {
const pricingModuleService: IPricingModuleService = container.resolve(
"pricingModuleService"
)
return pricingModuleService.createRuleTypes([
{
name: "region_id",
rule_attribute: "region_id",
},
])
}
@@ -0,0 +1,51 @@
import { MedusaContainer } from "@medusajs/modules-sdk"
import {
CreatePriceSetDTO,
IPricingModuleService,
PriceSetDTO,
} from "@medusajs/types"
const defaultPrices = [
{
amount: 3000,
currency_code: "usd",
rules: {},
},
]
const defaultPriceSetRules = [{ rule_attribute: "region_id" }]
export const createVariantPriceSet = async ({
container,
variantId,
prices = defaultPrices,
rules = defaultPriceSetRules,
}: {
container: MedusaContainer
variantId: string
prices?: CreatePriceSetDTO["prices"]
rules?: CreatePriceSetDTO["rules"]
}): Promise<PriceSetDTO> => {
const remoteLink = container.resolve("remoteLink")
const pricingModuleService: IPricingModuleService = container.resolve(
"pricingModuleService"
)
const priceSet = await pricingModuleService.create({
rules,
prices,
})
await remoteLink.create({
productService: {
variant_id: variantId,
},
pricingService: {
price_set_id: priceSet.id,
},
})
return await pricingModuleService.retrieve(priceSet.id, {
relations: ["money_amounts"],
})
}
@@ -5,6 +5,10 @@ const DB_USERNAME = process.env.DB_USERNAME
const DB_PASSWORD = process.env.DB_PASSWORD
const DB_NAME = process.env.DB_TEMP_NAME
const DB_URL = `postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}/${DB_NAME}`
process.env.POSTGRES_URL = DB_URL
const enablePricing = process.env.MEDUSA_FF_ISOLATE_PRICING_DOMAIN == "true"
const enableProduct = process.env.MEDUSA_FF_ISOLATE_PRODUCT_DOMAIN == "true"
module.exports = {
plugins: [
@@ -33,6 +37,8 @@ module.exports = {
database_extra: { idle_in_transaction_session_timeout: 0 },
},
featureFlags: {
isolate_product_domain: enableProduct,
isolate_pricing_domain: enablePricing,
workflows: {
[Workflows.CreateProducts]: true,
[Workflows.UpdateProducts]: true,
@@ -59,5 +65,10 @@ module.exports = {
resources: "shared",
resolve: "@medusajs/product",
},
[Modules.PRICING]: {
scope: "internal",
resources: "shared",
resolve: "@medusajs/pricing",
},
},
}
+2
View File
@@ -13,6 +13,8 @@
"@medusajs/event-bus-local": "workspace:*",
"@medusajs/inventory": "workspace:^",
"@medusajs/medusa": "workspace:*",
"@medusajs/modules-sdk": "workspace:^",
"@medusajs/pricing": "workspace:^",
"@medusajs/product": "workspace:^",
"faker": "^5.5.3",
"medusa-fulfillment-webshipper": "workspace:*",
@@ -1,6 +1,6 @@
import { Modules } from "@medusajs/modules-sdk"
import { ModuleJoinerConfig } from "@medusajs/types"
import { LINKS } from "../links"
import { ModuleJoinerConfig } from "@medusajs/types"
import { Modules } from "@medusajs/modules-sdk"
export const ProductVariantPriceSet: ModuleJoinerConfig = {
serviceName: LINKS.ProductVariantPriceSet,
@@ -178,10 +178,8 @@ export async function runMigrations(
allLinks.add(serviceKey)
if (
(!primary.isInternalService &&
!modulesLoadedKeys.includes(primary.serviceName)) ||
(!foreign.isInternalService &&
!modulesLoadedKeys.includes(foreign.serviceName))
!modulesLoadedKeys.includes(primary.serviceName) ||
!modulesLoadedKeys.includes(foreign.serviceName)
) {
continue
}
@@ -1,6 +1,6 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductServiceMock } from "../../../../../services/__mocks__/product"
import { request } from "../../../../../helpers/test-request"
describe("GET /admin/products/:id", () => {
describe("successfully gets a product", () => {
@@ -1,9 +1,9 @@
import { PricingService, ProductService } from "../../../../services"
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import { EntityManager } from "typeorm"
import { IsString } from "class-validator"
import { validator } from "../../../../utils/validator"
import { EntityManager } from "typeorm"
/**
* @oas [post] /admin/products/{id}/options
@@ -91,7 +91,7 @@ export default async (req, res) => {
relations: defaultAdminProductRelations,
})
const [product] = await pricingService.setProductPrices([rawProduct])
const [product] = await pricingService.setAdminProductPricing([rawProduct])
res.json({ product })
}
@@ -1,3 +1,5 @@
import { IInventoryService, WorkflowTypes } from "@medusajs/types"
import { Workflows, createProducts } from "@medusajs/workflows"
import {
IsArray,
IsBoolean,
@@ -37,17 +39,15 @@ import {
} from "./transaction/create-product-variant"
import { DistributedTransaction } from "@medusajs/orchestration"
import { IInventoryService, WorkflowTypes } from "@medusajs/types"
import { FlagRouter } from "@medusajs/utils"
import { createProducts, Workflows } from "@medusajs/workflows"
import { Type } from "class-transformer"
import { EntityManager } from "typeorm"
import IsolateProductDomainFeatureFlag from "../../../../loaders/feature-flags/isolate-product-domain"
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
import { ProductStatus } from "../../../../models"
import { Logger } from "../../../../types/global"
import { validator } from "../../../../utils"
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
import IsolateProductDomainFeatureFlag from "../../../../loaders/feature-flags/isolate-product-domain"
/**
* @oas [post] /admin/products
@@ -269,7 +269,9 @@ export default async (req, res) => {
})
}
const [pricedProduct] = await pricingService.setProductPrices([rawProduct])
const [pricedProduct] = await pricingService.setAdminProductPricing([
rawProduct,
])
res.json({ product: pricedProduct })
}
@@ -1,5 +1,7 @@
import { IInventoryService } from "@medusajs/types"
import { Type } from "class-transformer"
import {
CreateProductVariantInput,
ProductVariantPricesCreateReq,
} from "../../../../types/product-variant"
import {
IsArray,
IsBoolean,
@@ -9,20 +11,19 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import { EntityManager } from "typeorm"
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import {
PricingService,
ProductService,
ProductVariantInventoryService,
ProductVariantService,
} from "../../../../services"
import {
CreateProductVariantInput,
ProductVariantPricesCreateReq,
} from "../../../../types/product-variant"
import { validator } from "../../../../utils/validator"
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import { EntityManager } from "typeorm"
import { IInventoryService } from "@medusajs/types"
import { Type } from "class-transformer"
import { createVariantsTransaction } from "./transaction/create-product-variant"
import { validator } from "../../../../utils/validator"
/**
* @oas [post] /admin/products/{id}/variants
@@ -152,7 +153,7 @@ export default async (req, res) => {
relations: defaultAdminProductRelations,
})
const [product] = await pricingService.setProductPrices([rawProduct])
const [product] = await pricingService.setAdminProductPricing([rawProduct])
res.json({ product })
}
@@ -81,7 +81,7 @@ export default async (req, res) => {
relations: defaultAdminProductRelations,
})
const [product] = await pricingService.setProductPrices([data])
const [product] = await pricingService.setAdminProductPricing([data])
res.json({
variant_id,
@@ -1,7 +1,8 @@
import { PricingService, ProductService } from "../../../../services"
import IsolateProductDomainFeatureFlag from "../../../../loaders/feature-flags/isolate-product-domain"
import { defaultAdminProductRemoteQueryObject } from "./index"
import { MedusaError } from "@medusajs/utils"
import { defaultAdminProductRemoteQueryObject } from "./index"
/**
* @oas [get] /admin/products/{id}
@@ -80,8 +81,8 @@ export default async (req, res) => {
const product = rawProduct
if (!shouldSetPricing) {
await pricingService.setProductPrices([product])
if (shouldSetPricing) {
await pricingService.setAdminProductPricing([product])
}
res.json({ product })
@@ -9,10 +9,10 @@ import {
import { FilterableProductProps } from "../../../../types/product"
import { IInventoryService } from "@medusajs/types"
import IsolateProductDomainFeatureFlag from "../../../../loaders/feature-flags/isolate-product-domain"
import { PricedProduct } from "../../../../types/pricing"
import { Product } from "../../../../models"
import { Type } from "class-transformer"
import IsolateProductDomainFeatureFlag from "../../../../loaders/feature-flags/isolate-product-domain"
import { defaultAdminProductRemoteQueryObject } from "./index"
/**
@@ -275,7 +275,7 @@ export default async (req, res) => {
)
if (shouldSetPricing) {
products = await pricingService.setProductPrices(rawProducts)
products = await pricingService.setAdminProductPricing(rawProducts)
}
// We only set availability if variants are requested
@@ -1,9 +1,9 @@
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import { IsString } from "class-validator"
import { validator } from "../../../../utils/validator"
import { EntityManager } from "typeorm"
import { IsString } from "class-validator"
import { PricingService } from "../../../../services"
import { validator } from "../../../../utils/validator"
/**
* @oas [post] /admin/products/{id}/metadata
@@ -96,7 +96,7 @@ export default async (req, res) => {
relations: defaultAdminProductRelations,
})
const [product] = await pricingService.setProductPrices([rawProduct])
const [product] = await pricingService.setAdminProductPricing([rawProduct])
res.status(200).json({ product })
}
@@ -1,9 +1,9 @@
import { PricingService, ProductService } from "../../../../services"
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import { IsString } from "class-validator"
import { PricingService, ProductService } from "../../../../services"
import { validator } from "../../../../utils/validator"
import { EntityManager } from "typeorm"
import { IsString } from "class-validator"
import { validator } from "../../../../utils/validator"
/**
* @oas [post] /admin/products/{id}/options/{option_id}
@@ -92,7 +92,7 @@ export default async (req, res) => {
relations: defaultAdminProductRelations,
})
const [product] = await pricingService.setProductPrices([rawProduct])
const [product] = await pricingService.setAdminProductPricing([rawProduct])
res.json({ product })
}
@@ -137,7 +137,6 @@ export default async (req, res) => {
req.scope.resolve("inventoryService")
const manager: EntityManager = req.scope.resolve("manager")
const productModuleService = req.scope.resolve("productModuleService")
const featureFlagRouter: FlagRouter = req.scope.resolve("featureFlagRouter")
@@ -211,6 +210,7 @@ export default async (req, res) => {
prices: variant.prices || [],
})
variantsToCreate.push(variant)
continue
}
@@ -298,7 +298,7 @@ export default async (req, res) => {
})
}
const [product] = await pricingService.setProductPrices([rawProduct])
const [product] = await pricingService.setAdminProductPricing([rawProduct])
res.json({ product })
}
@@ -1,3 +1,7 @@
import { WorkflowTypes } from "@medusajs/types"
import { FlagRouter } from "@medusajs/utils"
import { UpdateProductVariants } from "@medusajs/workflows"
import { Type } from "class-transformer"
import {
IsArray,
IsBoolean,
@@ -7,15 +11,15 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import { EntityManager } from "typeorm"
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import IsolatePricingDomainFeatureFlag from "../../../../loaders/feature-flags/isolate-pricing-domain"
import {
PricingService,
ProductService,
ProductVariantService,
} from "../../../../services"
import { Type } from "class-transformer"
import { EntityManager } from "typeorm"
import { PriceSelectionParams } from "../../../../types/price-selection"
import { ProductVariantPricesUpdateReq } from "../../../../types/product-variant"
import { validator } from "../../../../utils/validator"
@@ -106,6 +110,13 @@ import { validator } from "../../../../utils/validator"
export default async (req, res) => {
const { id, variant_id } = req.params
const manager: EntityManager = req.scope.resolve("manager")
const productService: ProductService = req.scope.resolve("productService")
const pricingService: PricingService = req.scope.resolve("pricingService")
const featureFlagRouter: FlagRouter = req.scope.resolve("featureFlagRouter")
const productVariantService: ProductVariantService = req.scope.resolve(
"productVariantService"
)
const validated = await validator(
AdminPostProductsProductVariantsVariantReq,
req.body
@@ -113,21 +124,36 @@ export default async (req, res) => {
const validatedQueryParams = await validator(PriceSelectionParams, req.query)
const productService: ProductService = req.scope.resolve("productService")
const pricingService: PricingService = req.scope.resolve("pricingService")
const productVariantService: ProductVariantService = req.scope.resolve(
"productVariantService"
)
if (featureFlagRouter.isFeatureEnabled(IsolatePricingDomainFeatureFlag.key)) {
const updateVariantsWorkflow = UpdateProductVariants.updateProductVariants(
req.scope
)
const manager: EntityManager = req.scope.resolve("manager")
await manager.transaction(async (transactionManager) => {
await productVariantService
.withTransaction(transactionManager)
.update(variant_id, {
product_id: id,
...validated,
})
})
const input = {
productVariants: [
{
id: variant_id,
...validated,
},
] as WorkflowTypes.ProductWorkflow.UpdateProductVariantsInputDTO[],
}
await updateVariantsWorkflow.run({
input,
context: {
manager,
},
})
} else {
await manager.transaction(async (transactionManager) => {
await productVariantService
.withTransaction(transactionManager)
.update(variant_id, {
product_id: id,
...validated,
})
})
}
const rawProduct = await productService.retrieve(id, {
select: defaultAdminProductFields,
@@ -70,7 +70,7 @@ export default async (req, res) => {
req.retrieveConfig
)
const [variant] = await pricingService.setVariantPrices([rawVariant])
const [variant] = await pricingService.setAdminVariantPricing([rawVariant])
res.status(200).json({ variant })
}
@@ -192,7 +192,7 @@ export default async (req, res) => {
currencyCode = region.currency_code
}
let variants = await pricingService.setVariantPrices(rawVariants, {
let variants = await pricingService.setAdminVariantPricing(rawVariants, {
cart_id: req.validatedQuery.cart_id,
region_id: regionId,
currency_code: currencyCode,
@@ -1,3 +1,4 @@
import { IsOptional, IsString } from "class-validator"
import {
CartService,
PricingService,
@@ -5,13 +6,12 @@ import {
ProductVariantInventoryService,
RegionService,
} from "../../../../services"
import { IsOptional, IsString } from "class-validator"
import { MedusaError } from "@medusajs/utils"
import IsolateProductDomain from "../../../../loaders/feature-flags/isolate-product-domain"
import { PriceSelectionParams } from "../../../../types/price-selection"
import { cleanResponseData } from "../../../../utils"
import IsolateProductDomain from "../../../../loaders/feature-flags/isolate-product-domain"
import { defaultStoreProductRemoteQueryObject } from "./index"
import { MedusaError } from "@medusajs/utils"
/**
* @oas [get] /store/products/{id}
@@ -17,14 +17,14 @@ import { Transform, Type } from "class-transformer"
import { DateComparisonOperator } from "../../../../types/common"
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
import { IsType } from "../../../../utils/validators/is-type"
import IsolateProductDomain from "../../../../loaders/feature-flags/isolate-product-domain"
import { PriceSelectionParams } from "../../../../types/price-selection"
import PricingService from "../../../../services/pricing"
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
import { cleanResponseData } from "../../../../utils/clean-response-data"
import { defaultStoreCategoryScope } from "../product-categories"
import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
import IsolateProductDomain from "../../../../loaders/feature-flags/isolate-product-domain"
import { defaultStoreProductRemoteQueryObject } from "./index"
import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
/**
* @oas [get] /store/products
@@ -7,9 +7,7 @@ import {
} from "../../../../services"
import { IsOptional, IsString } from "class-validator"
import { FindParams } from "../../../../types/common"
import { PriceSelectionParams } from "../../../../types/price-selection"
import { defaultStoreVariantRelations } from "."
import { validator } from "../../../../utils/validator"
/**
@@ -81,10 +79,11 @@ export default async (req, res) => {
req.scope.resolve("productVariantInventoryService")
const cartService: CartService = req.scope.resolve("cartService")
const regionService: RegionService = req.scope.resolve("regionService")
const featureFlagRouter = req.scope.resolve("featureFlagRouter")
const customer_id = req.user?.customer_id
const rawVariant = await variantService.retrieve(id, req.retrieveConfig)
const variant = await variantService.retrieve(id, req.retrieveConfig)
let sales_channel_id = validated.sales_channel_id
if (req.publishableApiKeyScopes?.sales_channel_ids.length === 1) {
@@ -104,19 +103,27 @@ export default async (req, res) => {
currencyCode = region.currency_code
}
const variantRes = await pricingService.setVariantPrices([rawVariant], {
cart_id: validated.cart_id,
customer_id: customer_id,
region_id: regionId,
currency_code: currencyCode,
include_discount_prices: true,
})
const decoratePromises: Promise<any>[] = []
const [variant] = await productVariantInventoryService.setVariantAvailability(
variantRes,
sales_channel_id
decoratePromises.push(
(await pricingService.setVariantPrices([variant], {
cart_id: validated.cart_id,
customer_id: customer_id,
region_id: regionId,
currency_code: currencyCode,
include_discount_prices: true,
})) as any
)
decoratePromises.push(
(await productVariantInventoryService.setVariantAvailability(
[variant],
sales_channel_id
)) as any
)
await Promise.all(decoratePromises)
res.json({ variant })
}
@@ -1,4 +1,3 @@
import { IsInt, IsOptional, IsString } from "class-validator"
import {
CartService,
PricingService,
@@ -6,12 +5,13 @@ import {
ProductVariantService,
RegionService,
} from "../../../../services"
import { IsInt, IsOptional, IsString } from "class-validator"
import { Type } from "class-transformer"
import { IsType } from "../../../../utils/validators/is-type"
import { NumericalComparisonOperator } from "../../../../types/common"
import { PriceSelectionParams } from "../../../../types/price-selection"
import { Type } from "class-transformer"
import { validator } from "../../../../utils/validator"
import { IsType } from "../../../../utils/validators/is-type"
/**
* @oas [get] /store/variants
@@ -154,11 +154,9 @@ export default async (req, res) => {
const productVariantInventoryService: ProductVariantInventoryService =
req.scope.resolve("productVariantInventoryService")
const regionService: RegionService = req.scope.resolve("regionService")
const featureFlagRouter = req.scope.resolve("featureFlagRouter")
const rawVariants = await variantService.list(
filterableFields,
req.listConfig
)
const variants = await variantService.list(filterableFields, req.listConfig)
let regionId = validated.region_id
let currencyCode = validated.currency_code
@@ -173,19 +171,26 @@ export default async (req, res) => {
currencyCode = region.currency_code
}
const pricedVariants = await pricingService.setVariantPrices(rawVariants, {
cart_id: validated.cart_id,
region_id: regionId,
currency_code: currencyCode,
customer_id: customer_id,
include_discount_prices: true,
})
const decoratePromises: Promise<any>[] = []
const variants = await productVariantInventoryService.setVariantAvailability(
pricedVariants,
sales_channel_id
decoratePromises.push(
(await pricingService.setVariantPrices(variants, {
cart_id: validated.cart_id,
region_id: regionId,
currency_code: currencyCode,
customer_id: customer_id,
include_discount_prices: true,
})) as any
)
decoratePromises.push(
(await productVariantInventoryService.setVariantAvailability(
variants,
sales_channel_id
)) as any
)
await Promise.all(decoratePromises)
res.json({ variants })
}
+38 -6
View File
@@ -1,14 +1,20 @@
import { asValue, createContainer } from "awilix"
import featureFlagLoader from "../loaders/feature-flags"
import Logger from "../loaders/logger"
import databaseLoader from "../loaders/database"
import configModuleLoader from "../loaders/config"
import getMigrations, {
getModuleSharedResources,
revertIsolatedModulesMigration,
runIsolatedModulesMigration,
} from "./utils/get-migrations"
import { createMedusaContainer } from "@medusajs/utils"
import configModuleLoader from "../loaders/config"
import databaseLoader from "../loaders/database"
import featureFlagLoader from "../loaders/feature-flags"
import IsolatePricingDomainFeatureFlag from "../loaders/feature-flags/isolate-pricing-domain"
import IsolateProductDomainFeatureFlag from "../loaders/feature-flags/isolate-product-domain"
import Logger from "../loaders/logger"
import { loadMedusaApp } from "../loaders/medusa-app"
import pgConnectionLoader from "../loaders/pg-connection"
const getDataSource = async (directory) => {
const configModule = configModuleLoader(directory)
const featureFlagRouter = featureFlagLoader(configModule)
@@ -31,6 +37,25 @@ const getDataSource = async (directory) => {
})
}
const runLinkMigrations = async (directory) => {
const configModule = configModuleLoader(directory)
const container = createMedusaContainer()
await pgConnectionLoader({ configModule, container })
const { runMigrations } = await loadMedusaApp(
{ configModule, container },
{ registerInContainer: false }
)
const options = {
database: {
clientUrl: configModule.projectConfig.database_url,
},
}
await runMigrations(options)
}
const main = async function ({ directory }) {
const args = process.argv
@@ -40,19 +65,26 @@ const main = async function ({ directory }) {
const configModule = configModuleLoader(directory)
const dataSource = await getDataSource(directory)
const featureFlagRouter = featureFlagLoader(configModule)
if (args[0] === "run") {
await dataSource.runMigrations()
await dataSource.destroy()
await runIsolatedModulesMigration(configModule)
Logger.info("Migrations completed.")
if (
featureFlagRouter.isFeatureEnabled(IsolateProductDomainFeatureFlag.key) ||
featureFlagRouter.isFeatureEnabled(IsolatePricingDomainFeatureFlag.key)
) {
await runLinkMigrations(directory)
}
process.exit()
Logger.info("Migrations completed.")
} else if (args[0] === "revert") {
await dataSource.undoLastMigration({ transaction: "all" })
await dataSource.destroy()
await revertIsolatedModulesMigration(configModule)
Logger.info("Migrations reverted.")
process.exit()
} else if (args[0] === "show") {
const unapplied = await dataSource.showMigrations()
Logger.info(unapplied)
+28 -16
View File
@@ -1,17 +1,4 @@
import express from "express"
import fs from "fs"
import { sync as existsSync } from "fs-exists-cached"
import { getConfigFile } from "medusa-core-utils"
import { track } from "medusa-telemetry"
import path from "path"
import { DataSource, DataSourceOptions } from "typeorm"
import loaders from "../loaders"
import { handleConfigError } from "../loaders/config"
import Logger from "../loaders/logger"
import featureFlagLoader from "../loaders/feature-flags"
import {
ProductCategoryService,
ProductService,
@@ -23,12 +10,25 @@ import {
StoreService,
UserService,
} from "../services"
import { ConfigModule } from "../types/global"
import { CreateProductInput } from "../types/product"
import { CreateProductCategoryInput } from "../types/product-category"
import getMigrations, { getModuleSharedResources } from "./utils/get-migrations"
import { ConfigModule } from "../types/global"
import { CreateProductCategoryInput } from "../types/product-category"
import { CreateProductInput } from "../types/product"
import { IPricingModuleService } from "@medusajs/types"
import IsolatePricingDomainFeatureFlag from "../loaders/feature-flags/isolate-pricing-domain"
import Logger from "../loaders/logger"
import PublishableApiKeyService from "../services/publishable-api-key"
import { SalesChannel } from "../models"
import { sync as existsSync } from "fs-exists-cached"
import express from "express"
import featureFlagLoader from "../loaders/feature-flags"
import fs from "fs"
import { getConfigFile } from "medusa-core-utils"
import { handleConfigError } from "../loaders/config"
import loaders from "../loaders"
import path from "path"
import { track } from "medusa-telemetry"
type SeedOptions = {
directory: string
@@ -121,6 +121,9 @@ const seed = async function ({ directory, migrate, seedFile }: SeedOptions) {
const shippingProfileService: ShippingProfileService = container.resolve(
"shippingProfileService"
)
const pricingModuleService: IPricingModuleService = container.resolve(
"pricingModuleService"
)
/* eslint-enable */
await manager.transaction(async (tx) => {
@@ -131,6 +134,7 @@ const seed = async function ({ directory, migrate, seedFile }: SeedOptions) {
categories = [],
shipping_options,
users,
rule_types = [],
publishable_api_keys = [],
} = JSON.parse(fs.readFileSync(resolvedPath, `utf-8`))
@@ -270,6 +274,14 @@ const seed = async function ({ directory, migrate, seedFile }: SeedOptions) {
])
}
}
if (
featureFlagRouter.isFeatureEnabled(IsolatePricingDomainFeatureFlag.key)
) {
for (const ruleType of rule_types) {
await pricingModuleService.createRuleTypes(ruleType)
}
}
})
track("CLI_SEED_COMPLETED")
+2
View File
@@ -10,3 +10,5 @@ export * from "./types/routing"
export * from "./types/global"
export * from "./types/price-list"
export * from "./utils"
export * from "./joiner-config"
export * from "./modules-config"
@@ -1,10 +1,10 @@
import { FlagSettings } from "../../types/feature-flags"
const PricingIntegrationFeatureFlag: FlagSettings = {
const IsolatePricingDomainFeatureFlag: FlagSettings = {
key: "isolate_pricing_domain",
default_val: false,
env_key: "MEDUSA_FF_ISOLATE_PRICING_DOMAIN",
description: "[WIP] use price module integration for pricing",
}
export default PricingIntegrationFeatureFlag
export default IsolatePricingDomainFeatureFlag
@@ -535,7 +535,7 @@ export class RoutesLoader {
}
async load() {
performance.mark("file-base-routing-start" + this.rootDir)
performance && performance.mark("file-base-routing-start" + this.rootDir)
let apiExists = true
@@ -563,14 +563,16 @@ export class RoutesLoader {
await this.registerRoutes()
}
performance.mark("file-base-routing-end" + this.rootDir)
const timeSpent = performance
.measure(
"file-base-routing-measure" + this.rootDir,
"file-base-routing-start" + this.rootDir,
"file-base-routing-end" + this.rootDir
)
?.duration?.toFixed(2)
performance && performance.mark("file-base-routing-end" + this.rootDir)
const timeSpent =
performance &&
performance
.measure(
"file-base-routing-measure" + this.rootDir,
"file-base-routing-start" + this.rootDir,
"file-base-routing-end" + this.rootDir
)
?.duration?.toFixed(2)
log({
activityId: this.activityId,
+21 -72
View File
@@ -1,43 +1,42 @@
import {
ExternalModuleDeclaration,
InternalModuleDeclaration,
MedusaApp,
moduleLoader,
ModulesDefinition,
moduleLoader,
registerModules,
} from "@medusajs/modules-sdk"
import { Express, NextFunction, Request, Response } from "express"
import databaseLoader, { dataSource } from "./database"
import pluginsLoader, { registerPluginModels } from "./plugins"
import { Connection } from "typeorm"
import { ContainerRegistrationKeys } from "@medusajs/utils"
import { asValue } from "awilix"
import { Express, NextFunction, Request, Response } from "express"
import { createMedusaContainer } from "medusa-core-utils"
import { track } from "medusa-telemetry"
import { EOL } from "os"
import "reflect-metadata"
import requestIp from "request-ip"
import { Connection } from "typeorm"
import { joinerConfig } from "../joiner-config"
import modulesConfig from "../modules-config"
import { MedusaContainer } from "../types/global"
import { isObject, remoteQueryFetchData } from "../utils"
import apiLoader from "./api"
import loadConfig from "./config"
import databaseLoader, { dataSource } from "./database"
import defaultsLoader from "./defaults"
import expressLoader from "./express"
import featureFlagsLoader from "./feature-flags"
import IsolatePricingDomainFeatureFlag from "./feature-flags/isolate-pricing-domain"
import IsolateProductDomainFeatureFlag from "./feature-flags/isolate-product-domain"
import Logger from "./logger"
import { joinerConfig } from "../joiner-config"
import loadConfig from "./config"
import modelsLoader from "./models"
import passportLoader from "./passport"
import pgConnectionLoader from "./pg-connection"
import pluginsLoader, { registerPluginModels } from "./plugins"
import redisLoader from "./redis"
import repositoriesLoader from "./repositories"
import searchIndexLoader from "./search-index"
import servicesLoader from "./services"
import strategiesLoader from "./strategies"
import subscribersLoader from "./subscribers"
import { ConfigModule } from "@medusajs/types"
import loadMedusaApp from "./medusa-app"
type Options = {
directory: string
@@ -45,34 +44,6 @@ type Options = {
isTest: boolean
}
/**
* Merge the modules config from the medusa-config file with the modules config from medusa package
* @param modules
* @param medusaInternalModulesConfig
*/
function mergeModulesConfig(
modules: ConfigModule["modules"],
medusaInternalModulesConfig
) {
for (const [moduleName, moduleConfig] of Object.entries(modules as any)) {
const moduleDefinition = ModulesDefinition[moduleName]
if (moduleDefinition?.isLegacy) {
continue
}
const isModuleEnabled = moduleConfig === true || isObject(moduleConfig)
if (!isModuleEnabled) {
delete medusaInternalModulesConfig[moduleName]
} else {
medusaInternalModulesConfig[moduleName] = moduleConfig as Partial<
InternalModuleDeclaration | ExternalModuleDeclaration
>
}
}
}
export default async ({
directory: rootDirectory,
expressApp,
@@ -138,11 +109,7 @@ export default async ({
track("MODULES_INIT_STARTED")
await moduleLoader({
container,
moduleResolutions: registerModules(configModule?.modules, {
loadLegacyOnly: featureFlagRouter.isFeatureEnabled(
IsolateProductDomainFeatureFlag.key
),
}),
moduleResolutions: registerModules(configModule?.modules),
logger: Logger,
})
const modAct = Logger.success(modulesActivity, "Modules initialized") || {}
@@ -167,6 +134,15 @@ export default async ({
[ContainerRegistrationKeys.MANAGER]: asValue(dataSource.manager),
})
container.register("remoteQuery", asValue(null)) // ensure remoteQuery is always registered
// Only load non legacy modules, the legacy modules (non migrated yet) are retrieved by the registerModule above
if (
featureFlagRouter.isFeatureEnabled(IsolateProductDomainFeatureFlag.key) ||
featureFlagRouter.isFeatureEnabled(IsolatePricingDomainFeatureFlag.key)
) {
await loadMedusaApp({ configModule, container })
}
const servicesActivity = Logger.activity(`Initializing services${EOL}`)
track("SERVICES_INIT_STARTED")
servicesLoader({ container, configModule, isTest })
@@ -226,32 +202,5 @@ export default async ({
Logger.success(searchActivity, "Indexing event emitted") || {}
track("SEARCH_ENGINE_INDEXING_COMPLETED", { duration: searchAct.duration })
// Only load non legacy modules, the legacy modules (non migrated yet) are retrieved by the registerModule above
if (featureFlagRouter.isFeatureEnabled(IsolateProductDomainFeatureFlag.key)) {
mergeModulesConfig(configModule.modules ?? {}, modulesConfig)
const { query, modules } = await MedusaApp({
modulesConfig,
servicesConfig: joinerConfig,
remoteFetchData: remoteQueryFetchData(container),
sharedContainer: container,
injectedDependencies: {
[ContainerRegistrationKeys.PG_CONNECTION]: container.resolve(
ContainerRegistrationKeys.PG_CONNECTION
),
},
})
// Medusa app load all non legacy modules, so we need to register them in the container since they are into their own container
// We might decide to do it elsewhere but for now I think it is fine
for (const [serviceKey, moduleService] of Object.entries(modules)) {
container.register(
ModulesDefinition[serviceKey].registrationName,
asValue(moduleService)
)
}
container.register("remoteQuery", asValue(query))
}
return { container, dbConnection, app: expressApp }
}
+67
View File
@@ -0,0 +1,67 @@
import { CommonTypes, MedusaContainer } from "@medusajs/types"
import {
MedusaApp,
MedusaAppOutput,
ModulesDefinition,
} from "@medusajs/modules-sdk"
import { ContainerRegistrationKeys } from "@medusajs/utils"
import { asValue } from "awilix"
import { joinerConfig } from "../joiner-config"
import { mergeModulesConfig } from "../utils/merge-modules-config"
import modulesConfig from "../modules-config"
import { remoteQueryFetchData } from ".."
export const loadMedusaApp = async (
{
configModule,
container,
}: { configModule: CommonTypes.ConfigModule; container: MedusaContainer },
config = { registerInContainer: true }
): Promise<MedusaAppOutput> => {
mergeModulesConfig(configModule.modules ?? {}, modulesConfig)
const injectedDependencies = {
[ContainerRegistrationKeys.PG_CONNECTION]: container.resolve(
ContainerRegistrationKeys.PG_CONNECTION
),
}
const sharedResourcesConfig = {
database: {
clientUrl: configModule.projectConfig.database_url,
driverOptions: configModule.projectConfig.database_extra,
},
}
const medusaApp = await MedusaApp({
modulesConfig,
servicesConfig: joinerConfig,
remoteFetchData: remoteQueryFetchData(container),
sharedContainer: container,
sharedResourcesConfig,
injectedDependencies,
})
if (!config.registerInContainer) {
return medusaApp
}
container.register("remoteLink", asValue(medusaApp.link))
const { query, modules } = medusaApp
// Medusa app load all non legacy modules, so we need to register them in the container since they are into their own container
// We might decide to do it elsewhere but for now I think it is fine
for (const [serviceKey, moduleService] of Object.entries(modules)) {
container.register(
ModulesDefinition[serviceKey].registrationName,
asValue(moduleService)
)
}
container.register("remoteQuery", asValue(query))
return medusaApp
}
export default loadMedusaApp
+5 -3
View File
@@ -1,13 +1,13 @@
import { asValue, AwilixContainer } from "awilix"
import { ConfigModule } from "../types/global"
import { ContainerRegistrationKeys, ModulesSdkUtils } from "@medusajs/utils"
import { AwilixContainer, asValue } from "awilix"
import { ConfigModule } from "../types/global"
type Options = {
configModule: ConfigModule
container: AwilixContainer
}
export default async ({ container, configModule }: Options): Promise<void> => {
export default async ({ container, configModule }: Options): Promise<any> => {
if (container.hasRegistration(ContainerRegistrationKeys.PG_CONNECTION)) {
return
}
@@ -36,4 +36,6 @@ export default async ({ container, configModule }: Options): Promise<void> => {
ContainerRegistrationKeys.PG_CONNECTION,
asValue(pgConnection)
)
return pgConnection
}
@@ -0,0 +1,44 @@
import { AwilixContainer } from "awilix"
import { IPricingModuleService } from "@medusajs/types"
import dotenv from "dotenv"
import express from "express"
import loaders from "../loaders"
dotenv.config()
export const createDefaultRuleTypes = async (container: AwilixContainer) => {
const pricingModuleService: IPricingModuleService = container.resolve(
"pricingModuleService"
)
const existing = await pricingModuleService.listRuleTypes(
{ rule_attribute: ["region_id"] },
{ take: 1 }
)
if (existing.length) {
return
}
await pricingModuleService.createRuleTypes([
{ name: "region_id", rule_attribute: "region_id" },
])
}
const migrate = async function ({ directory }) {
const app = express()
const { container } = await loaders({
directory,
expressApp: app,
isTest: false,
})
return await createDefaultRuleTypes(container)
}
migrate({ directory: process.cwd() })
.then(() => {
console.log("Created default rule types")
})
.catch(() => {
console.log("Failed to create rule types")
})
@@ -0,0 +1,142 @@
import { FlagRouter, MedusaError } from "@medusajs/utils"
import { IPricingModuleService, MedusaContainer } from "@medusajs/types"
import { AwilixContainer } from "awilix"
import { EntityManager } from "typeorm"
import IsolatePricingDomainFeatureFlag from "../loaders/feature-flags/isolate-pricing-domain"
import { Modules } from "@medusajs/modules-sdk"
import { ProductVariant } from "../models"
import { ProductVariantService } from "../services"
import { createDefaultRuleTypes } from "./create-default-rule-types"
import dotenv from "dotenv"
import express from "express"
import loadMedusaApp from "../loaders/medusa-app"
import loaders from "../loaders"
dotenv.config()
const BATCH_SIZE = 100
const migrateProductVariant = async (
variant: ProductVariant,
{
container,
}: { container: MedusaContainer; transactionManager: EntityManager }
) => {
const pricingService: IPricingModuleService = container.resolve(
"pricingModuleService"
)
const configModule = await container.resolve("configModule")
const { link } = await loadMedusaApp(
{ configModule, container },
{ registerInContainer: false }
)
if (!link) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't migrate money_amounts: Pricing module is not configured correctly"
)
}
const priceSet = await pricingService.create({
rules: [{ rule_attribute: "region_id" }],
prices: variant.prices.map((price) => ({
rules: {
region_id: price.region_id,
},
currency_code: price.currency_code,
min_quantity: price.min_quantity,
max_quantity: price.max_quantity,
amount: price.amount,
})),
})
await link.create({
productService: {
variant_id: variant.id,
},
pricingService: {
price_set_id: priceSet.id,
},
})
}
const processBatch = async (
variants: ProductVariant[],
container: MedusaContainer
) => {
const manager = container.resolve("manager")
return await manager.transaction(async (transactionManager) => {
await Promise.all(
variants.map(async (variant) => {
await migrateProductVariant(variant, {
container,
transactionManager,
})
})
)
})
}
const migrate = async function ({ directory }) {
const app = express()
const { container } = await loaders({
directory,
expressApp: app,
isTest: false,
})
const variantService: ProductVariantService = await container.resolve(
"productVariantService"
)
const featureFlagRouter: FlagRouter = await container.resolve(
"featureFlagRouter"
)
if (
!featureFlagRouter.isFeatureEnabled(IsolatePricingDomainFeatureFlag.key) &&
!featureFlagRouter.isFeatureEnabled(Modules.PRICING)
) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Pricing module not enabled"
)
}
await createDefaultRuleTypes(container)
const [variants, totalCount] = await variantService.listAndCount(
{},
{ take: BATCH_SIZE, order: { id: "ASC" }, relations: ["prices"] }
)
await processBatch(variants, container)
let processedCount = variants.length
console.log(`Processed ${processedCount} of ${totalCount}`)
while (processedCount < totalCount) {
const nextBatch = await variantService.list(
{},
{
skip: processedCount,
take: BATCH_SIZE,
order: { id: "ASC" },
relations: ["prices"],
}
)
await processBatch(nextBatch, container)
processedCount += nextBatch.length
console.log(`Processed ${processedCount} of ${totalCount}`)
}
console.log("Done")
process.exit(0)
}
migrate({ directory: process.cwd() })
@@ -5,9 +5,15 @@ export const PricingServiceMock = {
setProductPrices: jest.fn().mockImplementation((prod) => {
return Promise.resolve(prod)
}),
setAdminProductPricing: jest.fn().mockImplementation((prod) => {
return Promise.resolve(prod)
}),
setVariantPrices: jest.fn().mockImplementation(([variant]) => {
return Promise.resolve([variant])
}),
setAdminVariantPricing: jest.fn().mockImplementation(([variant]) => {
return Promise.resolve([variant])
}),
setShippingOptionPrices: jest.fn().mockImplementation((opts) => {
return Promise.resolve(opts)
}),
+16 -13
View File
@@ -1,28 +1,29 @@
import { FlagRouter } from "@medusajs/utils"
import { asClass, asValue, createContainer } from "awilix"
import _ from "lodash"
import { MedusaError } from "medusa-core-utils"
import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import { IsNull, Not } from "typeorm"
import { PaymentSessionStatus } from "../../models"
import TaxCalculationStrategy from "../../strategies/tax-calculation"
import { cacheServiceMock } from "../__mocks__/cache"
import { NewTotalsService, PricingService, TaxProviderService } from "../index"
import { asClass, asValue, createContainer } from "awilix"
import CartService from "../cart"
import { CustomerServiceMock } from "../__mocks__/customer"
import { EventBusServiceMock } from "../__mocks__/event-bus"
import { LineItemServiceMock } from "../__mocks__/line-item"
import { FlagRouter } from "@medusajs/utils"
import { LineItemAdjustmentServiceMock } from "../__mocks__/line-item-adjustment"
import { newTotalsServiceMock } from "../__mocks__/new-totals"
import { LineItemServiceMock } from "../__mocks__/line-item"
import { MedusaError } from "medusa-core-utils"
import { PaymentProviderServiceMock } from "../__mocks__/payment-provider"
import { PaymentSessionStatus } from "../../models"
import { ProductServiceMock } from "../__mocks__/product"
import { ProductVariantServiceMock } from "../__mocks__/product-variant"
import { ProductVariantInventoryServiceMock } from "../__mocks__/product-variant-inventory"
import { ProductVariantServiceMock } from "../__mocks__/product-variant"
import { RegionServiceMock } from "../__mocks__/region"
import { ShippingOptionServiceMock } from "../__mocks__/shipping-option"
import { ShippingProfileServiceMock } from "../__mocks__/shipping-profile"
import { taxProviderServiceMock } from "../__mocks__/tax-provider"
import CartService from "../cart"
import { NewTotalsService, PricingService, TaxProviderService } from "../index"
import SystemTaxService from "../system-tax"
import TaxCalculationStrategy from "../../strategies/tax-calculation"
import _ from "lodash"
import { cacheServiceMock } from "../__mocks__/cache"
import { newTotalsServiceMock } from "../__mocks__/new-totals"
import { taxProviderServiceMock } from "../__mocks__/tax-provider"
const eventBusService = {
emit: jest.fn(),
@@ -2657,6 +2658,8 @@ describe("CartService", () => {
.register("taxProviderService", asClass(TaxProviderService))
.register("newTotalsService", asClass(NewTotalsService))
.register("cartService", asClass(CartService))
.register("remoteQuery", asValue(null))
.register("pricingModuleService", asValue(undefined))
.register("pricingService", asClass(PricingService))
const cartService = container.resolve("cartService")
+288 -7
View File
@@ -1,14 +1,22 @@
import { FlagRouter } from "@medusajs/utils"
import { MedusaError } from "medusa-core-utils"
import { EntityManager } from "typeorm"
import { ProductVariantService, RegionService, TaxProviderService } from "."
import { TransactionBaseService } from "../interfaces"
import {
CalculatedPriceSetDTO,
IPricingModuleService,
PriceSetMoneyAmountDTO,
RemoteJoinerQuery,
RemoteQueryFunction,
} from "@medusajs/types"
import { FlagRouter, removeNullish } from "@medusajs/utils"
import {
IPriceSelectionStrategy,
PriceSelectionContext,
} from "../interfaces/price-selection-strategy"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { Product, ProductVariant, Region, ShippingOption } from "../models"
import {
MoneyAmount,
Product,
ProductVariant,
Region,
ShippingOption,
} from "../models"
import {
PricedProduct,
PricedShippingOption,
@@ -17,7 +25,15 @@ import {
ProductVariantPricing,
TaxedPricing,
} from "../types/pricing"
import { ProductVariantService, RegionService, TaxProviderService } from "."
import { EntityManager } from "typeorm"
import IsolatePricingDomainFeatureFlag from "../loaders/feature-flags/isolate-pricing-domain"
import IsolateProductDomainFeatureFlag from "../loaders/feature-flags/isolate-product-domain"
import { MedusaError } from "medusa-core-utils"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { TaxServiceRate } from "../types/tax-service"
import { TransactionBaseService } from "../interfaces"
import { calculatePriceTaxAmount } from "../utils"
type InjectedDependencies = {
@@ -27,6 +43,8 @@ type InjectedDependencies = {
regionService: RegionService
priceSelectionStrategy: IPriceSelectionStrategy
featureFlagRouter: FlagRouter
remoteQuery: RemoteQueryFunction
pricingModuleService: IPricingModuleService
}
/**
@@ -38,6 +56,8 @@ class PricingService extends TransactionBaseService {
protected readonly priceSelectionStrategy: IPriceSelectionStrategy
protected readonly productVariantService: ProductVariantService
protected readonly featureFlagRouter: FlagRouter
protected readonly pricingModuleService: IPricingModuleService
protected readonly remoteQuery: RemoteQueryFunction
constructor({
productVariantService,
@@ -45,6 +65,8 @@ class PricingService extends TransactionBaseService {
regionService,
priceSelectionStrategy,
featureFlagRouter,
remoteQuery,
pricingModuleService,
}: InjectedDependencies) {
// eslint-disable-next-line prefer-rest-params
super(arguments[0])
@@ -54,6 +76,8 @@ class PricingService extends TransactionBaseService {
this.priceSelectionStrategy = priceSelectionStrategy
this.productVariantService = productVariantService
this.featureFlagRouter = featureFlagRouter
this.pricingModuleService = pricingModuleService
this.remoteQuery = remoteQuery
}
/**
@@ -160,6 +184,90 @@ class PricingService extends TransactionBaseService {
return taxedPricing
}
private async getProductVariantPricingModulePricing_(
variantPriceData: {
variantId: string
quantity?: number
}[],
context: PricingContext
) {
const variables = {
variant_id: variantPriceData.map((pricedata) => pricedata.variantId),
}
const query = {
product_variant_price_set: {
__args: variables,
fields: ["variant_id", "price_set_id"],
},
}
const variantPriceSets = await this.remoteQuery(query)
const variantIdToPriceSetIdMap: Map<string, string> = new Map(
variantPriceSets.map((variantPriceSet) => [
variantPriceSet.variant_id,
variantPriceSet.price_set_id,
])
)
const priceSetIds: string[] = variantPriceSets.map(
(variantPriceSet) => variantPriceSet.price_set_id
)
const queryContext: PriceSelectionContext = removeNullish(
context.price_selection
)
let priceSets: CalculatedPriceSetDTO[] = []
if (queryContext.currency_code) {
priceSets = (await this.pricingModuleService.calculatePrices(
{ id: priceSetIds },
{
context: queryContext as any,
}
)) as unknown as CalculatedPriceSetDTO[]
}
const priceSetMap = new Map<string, CalculatedPriceSetDTO>(
priceSets.map((priceSet) => [priceSet.id, priceSet])
)
const pricingResultMap = new Map()
variantPriceData.forEach(({ variantId }) => {
const priceSetId = variantIdToPriceSetIdMap.get(variantId)
const pricingResult: ProductVariantPricing = {
prices: [] as MoneyAmount[],
original_price: null,
calculated_price: null,
calculated_price_type: null,
original_price_includes_tax: null,
calculated_price_includes_tax: null,
original_price_incl_tax: null,
calculated_price_incl_tax: null,
original_tax: null,
calculated_tax: null,
tax_rates: null,
}
if (priceSetId) {
const prices = priceSetMap.get(priceSetId)
if (prices) {
pricingResult.prices = [prices] as MoneyAmount[]
pricingResult.original_price = prices.amount
pricingResult.calculated_price = prices.amount
}
}
pricingResultMap.set(variantId, pricingResult)
})
return pricingResultMap
}
private async getProductVariantPricing_(
data: {
variantId: string
@@ -167,6 +275,17 @@ class PricingService extends TransactionBaseService {
}[],
context: PricingContext
): Promise<Map<string, ProductVariantPricing>> {
if (
this.featureFlagRouter.isFeatureEnabled(
IsolateProductDomainFeatureFlag.key
) &&
this.featureFlagRouter.isFeatureEnabled(
IsolatePricingDomainFeatureFlag.key
)
) {
return await this.getProductVariantPricingModulePricing_(data, context)
}
const variantsPricing = await this.priceSelectionStrategy
.withTransaction(this.activeManager_)
.calculateVariantPrice(data, context.price_selection)
@@ -509,6 +628,7 @@ class PricingService extends TransactionBaseService {
product.variants.map((productVariant): PricedVariant => {
const variantPricing = productsVariantsPricingMap.get(product.id)!
const pricing = variantPricing[productVariant.id]
Object.assign(productVariant, pricing)
return productVariant as unknown as PricedVariant
})
@@ -517,6 +637,167 @@ class PricingService extends TransactionBaseService {
})
}
private async getPricingModuleVariantMoneyAmounts(
variantIds: string[]
): Promise<Map<string, MoneyAmount[]>> {
const variables = {
variant_id: variantIds,
}
const query = {
product_variant_price_set: {
__args: variables,
fields: ["variant_id", "price_set_id"],
},
}
const variantPriceSets = await this.remoteQuery(query)
const priceSetIdToVariantIdMap: Map<string, string> = new Map(
variantPriceSets.map((variantPriceSet) => [
variantPriceSet.price_set_id,
variantPriceSet.variant_id,
])
)
const priceSetIds: string[] = variantPriceSets.map(
(variantPriceSet) => variantPriceSet.price_set_id
)
const priceSetMoneyAmounts: PriceSetMoneyAmountDTO[] =
await this.pricingModuleService.listPriceSetMoneyAmounts(
{
price_set_id: priceSetIds,
},
{
relations: [
"money_amount",
"price_set",
"price_rules",
"price_rules.rule_type",
],
}
)
const variantIdMoneyAmountMap = priceSetMoneyAmounts.reduce(
(map, priceSetMoneyAmount) => {
const variantId = priceSetIdToVariantIdMap.get(
priceSetMoneyAmount.price_set!.id
)
if (!variantId) {
return map
}
const regionId = priceSetMoneyAmount.price_rules!.find(
(pr) => pr.rule_type.rule_attribute === "region_id"
)?.value
const moneyAmount = {
...priceSetMoneyAmount.money_amount,
region_id: null as null | string,
}
if (regionId) {
moneyAmount.region_id = regionId
}
if (map.has(variantId)) {
map.get(variantId).push(moneyAmount)
} else {
map.set(variantId, [moneyAmount])
}
return map
},
new Map()
)
return variantIdMoneyAmountMap
}
async setAdminVariantPricing(
variants: ProductVariant[],
context: PriceSelectionContext = {}
): Promise<PricedVariant[]> {
if (
!this.featureFlagRouter.isFeatureEnabled(
IsolatePricingDomainFeatureFlag.key
)
) {
return await this.setVariantPrices(variants, context)
}
const variantIds = variants.map((variant) => variant.id)
const variantIdMoneyAmountMap =
await this.getPricingModuleVariantMoneyAmounts(variantIds)
return variants.map((variant) => {
const pricing: ProductVariantPricing = {
prices: variantIdMoneyAmountMap.get(variant.id) ?? [],
original_price: null,
calculated_price: null,
calculated_price_type: null,
original_price_includes_tax: null,
calculated_price_includes_tax: null,
original_price_incl_tax: null,
calculated_price_incl_tax: null,
original_tax: null,
calculated_tax: null,
tax_rates: null,
}
Object.assign(variant, pricing)
return variant as unknown as PricedVariant
})
}
async setAdminProductPricing(
products: Product[]
): Promise<(Product | PricedProduct)[]> {
if (
!this.featureFlagRouter.isFeatureEnabled(
IsolatePricingDomainFeatureFlag.key
)
) {
return await this.setProductPrices(products)
}
const variantIds = products
.map((product) => product.variants.map((variant) => variant.id).flat())
.flat()
const variantIdMoneyAmountMap =
await this.getPricingModuleVariantMoneyAmounts(variantIds)
return products.map((product) => {
if (!product?.variants?.length) {
return product
}
product.variants.map((productVariant): PricedVariant => {
const pricing: ProductVariantPricing = {
prices: variantIdMoneyAmountMap.get(productVariant.id) ?? [],
original_price: null,
calculated_price: null,
calculated_price_type: null,
original_price_includes_tax: null,
calculated_price_includes_tax: null,
original_price_incl_tax: null,
calculated_price_incl_tax: null,
original_tax: null,
calculated_tax: null,
tax_rates: null,
}
Object.assign(productVariant, pricing)
return productVariant as unknown as PricedVariant
})
return product
})
}
/**
* Gets the prices for a shipping option.
* @param shippingOption - the shipping option to get prices for
+11
View File
@@ -124,3 +124,14 @@ export type PricedVariant = Partial<ProductVariant> & ProductVariantPricing
export type PricedProduct = Omit<Partial<Product>, "variants"> & {
variants: PricedVariant[]
}
export type VariantPriceSetRes = {
id: string
title: string
price: PriceModulePrice | PriceModulePrice[]
}
type PriceModulePrice = {
variant_id: string
price_set_id: string
}
@@ -0,0 +1,36 @@
import {
ConfigModule,
ExternalModuleDeclaration,
InternalModuleDeclaration,
} from "@medusajs/types"
import { ModulesDefinition } from "@medusajs/modules-sdk"
import { isObject } from "./is-object"
/**
* Merge the modules config from the medusa-config file with the modules config from medusa package
* @param modules
* @param medusaInternalModulesConfig
*/
export function mergeModulesConfig(
modules: ConfigModule["modules"],
medusaInternalModulesConfig
) {
for (const [moduleName, moduleConfig] of Object.entries(modules as any)) {
const moduleDefinition = ModulesDefinition[moduleName]
if (moduleDefinition?.isLegacy) {
continue
}
const isModuleEnabled = moduleConfig === true || isObject(moduleConfig)
if (!isModuleEnabled) {
delete medusaInternalModulesConfig[moduleName]
} else {
medusaInternalModulesConfig[moduleName] = moduleConfig as Partial<
InternalModuleDeclaration | ExternalModuleDeclaration
>
}
}
}
@@ -139,7 +139,8 @@ export async function loadModuleMigrations(
): Promise<[Function | undefined, Function | undefined]> {
let loadedModule: ModuleExports
try {
loadedModule = (await import(resolution.resolutionPath as string)).default
loadedModule = await import(resolution.resolutionPath as string)
return [loadedModule.runMigrations, loadedModule.revertMigration]
} catch {
return [undefined, undefined]
+27 -10
View File
@@ -1,6 +1,3 @@
import { mergeTypeDefs } from "@graphql-tools/merge"
import { makeExecutableSchema } from "@graphql-tools/schema"
import { RemoteFetchDataCallback } from "@medusajs/orchestration"
import {
ExternalModuleDeclaration,
InternalModuleDeclaration,
@@ -26,15 +23,18 @@ import {
Modules,
} from "./definitions"
import { MedusaModule } from "./medusa-module"
import { RemoteFetchDataCallback } from "@medusajs/orchestration"
import { RemoteLink } from "./remote-link"
import { RemoteQuery } from "./remote-query"
import { cleanGraphQLSchema } from "./utils"
import { asValue } from "awilix"
import { makeExecutableSchema } from "@graphql-tools/schema"
import { mergeTypeDefs } from "@graphql-tools/merge"
const LinkModulePackage = "@medusajs/link-modules"
export type RunMigrationFn = (
options: Omit<LoaderOptions<ModuleServiceInitializeOptions>, "container">,
options?: ModuleServiceInitializeOptions,
injectedDependencies?: Record<any, any>
) => Promise<void>
@@ -162,6 +162,18 @@ function registerCustomJoinerConfigs(servicesConfig: ModuleJoinerConfig[]) {
}
}
export type MedusaAppOutput = {
modules: Record<string, LoadedModule | LoadedModule[]>
link: RemoteLink | undefined
query: (
query: string | RemoteJoinerQuery | object,
variables?: Record<string, unknown>
) => Promise<any>
entitiesMap?: Record<string, any>
notFound?: Record<string, Record<string, string>>
runMigrations: RunMigrationFn
}
export async function MedusaApp(
{
sharedContainer,
@@ -276,19 +288,24 @@ export async function MedusaApp(
return await remoteQuery.query(query, variables)
}
const runMigrations: RunMigrationFn = async (): Promise<void> => {
const runMigrations: RunMigrationFn = async (
linkModuleOptions
): Promise<void> => {
for (const moduleName of Object.keys(allModules)) {
const loadedModule = allModules[moduleName]
const moduleResolution = MedusaModule.getModuleResolutions(moduleName)
await MedusaModule.migrateUp(
loadedModule.definition.key,
loadedModule.resolutionPath,
loadedModule.options
moduleResolution.definition.key,
moduleResolution.resolutionPath as string,
moduleResolution.options
)
}
linkModuleMigration &&
(await linkModuleMigration(linkResolution.options, injectedDependencies))
(await linkModuleMigration({
options: linkModuleOptions,
injectedDependencies,
}))
}
return {
+23
View File
@@ -71,6 +71,7 @@ export class MedusaModule {
private static modules_: Map<string, ModuleAlias[]> = new Map()
private static loading_: Map<string, Promise<any>> = new Map()
private static joinerConfig_: Map<string, ModuleJoinerConfig> = new Map()
private static moduleResolutions_: Map<string, ModuleResolution> = new Map()
public static getLoadedModules(
aliases?: Map<string, string>
@@ -88,6 +89,7 @@ export class MedusaModule {
MedusaModule.instances_.clear()
MedusaModule.modules_.clear()
MedusaModule.joinerConfig_.clear()
MedusaModule.moduleResolutions_.clear()
}
public static isInstalled(moduleKey: string, alias?: string): boolean {
@@ -109,11 +111,29 @@ export class MedusaModule {
return [...MedusaModule.joinerConfig_.values()]
}
public static getModuleResolutions(moduleKey: string): ModuleResolution {
return MedusaModule.moduleResolutions_.get(moduleKey)!
}
public static getAllModuleResolutions(): ModuleResolution[] {
return [...MedusaModule.moduleResolutions_.values()]
}
public static setModuleResolution(
moduleKey: string,
resolution: ModuleResolution
): ModuleResolution {
MedusaModule.moduleResolutions_.set(moduleKey, resolution)
return resolution
}
public static setJoinerConfig(
moduleKey: string,
config: ModuleJoinerConfig
): ModuleJoinerConfig {
MedusaModule.joinerConfig_.set(moduleKey, config)
return config
}
@@ -261,6 +281,8 @@ export class MedusaModule {
MedusaModule.setJoinerConfig(keyName, joinerConfig)
}
MedusaModule.setModuleResolution(keyName, resolution)
MedusaModule.registerModule(keyName, {
key: keyName,
hash: hashKey,
@@ -379,6 +401,7 @@ export class MedusaModule {
}
}
MedusaModule.setModuleResolution(keyName, resolution)
MedusaModule.registerModule(keyName, {
key: keyName,
hash: hashKey,
+9 -7
View File
@@ -1,8 +1,3 @@
import {
RemoteFetchDataCallback,
RemoteJoiner,
toRemoteJoinerQuery,
} from "@medusajs/orchestration"
import {
JoinerRelationship,
JoinerServiceConfig,
@@ -11,7 +6,13 @@ import {
RemoteExpandProperty,
RemoteJoinerQuery,
} from "@medusajs/types"
import {
RemoteFetchDataCallback,
RemoteJoiner,
toRemoteJoinerQuery,
} from "@medusajs/orchestration"
import { isString, toPascalCase } from "@medusajs/utils"
import { MedusaModule } from "./medusa-module"
export class RemoteQuery {
@@ -28,14 +29,14 @@ export class RemoteQuery {
customRemoteFetchData?: RemoteFetchDataCallback
servicesConfig?: ModuleJoinerConfig[]
}) {
const servicesConfig_ = [...servicesConfig]
if (!modulesLoaded?.length) {
modulesLoaded = MedusaModule.getLoadedModules().map(
(mod) => Object.values(mod)[0]
)
}
const servicesConfig_ = [...servicesConfig]
for (const mod of modulesLoaded) {
if (!mod.__definition.isQueryable) {
continue
@@ -54,6 +55,7 @@ export class RemoteQuery {
}
this.customRemoteFetchData = customRemoteFetchData
this.remoteJoiner = new RemoteJoiner(
servicesConfig_ as JoinerServiceConfig[],
this.remoteFetchData.bind(this)
@@ -40,6 +40,7 @@ import {
MedusaContext,
MedusaError,
groupBy,
removeNullish,
shouldForceTransaction,
} from "@medusajs/utils"
@@ -312,7 +313,9 @@ export default class PricingModuleService<
sharedContext
)
const numberOfRules = ma.rules ? Object.entries(ma.rules).length : 0
const cleanRules = ma.rules ? removeNullish(ma.rules) : {}
const numberOfRules = Object.entries(cleanRules).length
const [priceSetMoneyAmount] =
await this.priceSetMoneyAmountService_.create(
@@ -328,7 +331,7 @@ export default class PricingModuleService<
)
if (numberOfRules) {
const priceSetRulesCreate = Object.entries(ma.rules).map(
const priceSetRulesCreate = Object.entries(cleanRules).map(
([k, v]) => ({
price_set_money_amount: priceSetMoneyAmount,
rule_type: ruleTypeMap.get(k),
@@ -1017,6 +1020,48 @@ export default class PricingModuleService<
]
}
@InjectManager("baseRepository_")
async listPriceSetMoneyAmounts(
filters: PricingTypes.FilterablePriceSetMoneyAmountProps = {},
config: FindConfig<PricingTypes.PriceSetMoneyAmountDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<PricingTypes.PriceSetMoneyAmountDTO[]> {
const records = await this.priceSetMoneyAmountService_.list(
filters,
config,
sharedContext
)
return this.baseRepository_.serialize<
PricingTypes.PriceSetMoneyAmountRulesDTO[]
>(records, {
populate: true,
})
}
@InjectManager("baseRepository_")
async listAndCountPriceSetMoneyAmounts(
filters: PricingTypes.FilterablePriceSetMoneyAmountProps = {},
config: FindConfig<PricingTypes.PriceSetMoneyAmountDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<[PricingTypes.PriceSetMoneyAmountDTO[], number]> {
const [records, count] =
await this.priceSetMoneyAmountService_.listAndCount(
filters,
config,
sharedContext
)
return [
await this.baseRepository_.serialize<
PricingTypes.PriceSetMoneyAmountRulesDTO[]
>(records, {
populate: true,
}),
count,
]
}
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
async createPriceSetMoneyAmountRules(
data: PricingTypes.CreatePriceSetMoneyAmountRulesDTO[],
+7 -2
View File
@@ -1,8 +1,8 @@
import { JoinerRelationship, JoinerServiceConfig } from "../joiner"
import { JoinerRelationship, JoinerServiceConfig, RemoteJoinerQuery } from "../joiner"
import { Logger } from "../logger"
import { MedusaContainer } from "../common"
import { RepositoryService } from "../dal"
import { Logger } from "../logger"
export type Constructor<T> = new (...args: any[]) => T
export * from "../common/medusa-container"
@@ -260,3 +260,8 @@ export type ModuleServiceInitializeCustomDataLayerOptions = {
[key: string]: Constructor<RepositoryService>
}
}
export type RemoteQueryFunction = (
query: string | RemoteJoinerQuery | object,
variables?: Record<string, unknown>
) => Promise<any> | null
@@ -39,8 +39,8 @@ export interface CreateMoneyAmountDTO {
currency_code: string
currency?: CreateCurrencyDTO
amount?: number
min_quantity?: number
max_quantity?: number
min_quantity?: number | null
max_quantity?: number | null
}
/**
@@ -1,4 +1,6 @@
import { BaseFilterable } from "../../dal"
import { MoneyAmountDTO } from "./money-amount"
import { PriceRuleDTO } from "./price-rule"
import { PriceSetDTO } from "./price-set"
/**
@@ -15,6 +17,8 @@ export interface PriceSetMoneyAmountDTO {
id: string
title?: string
price_set?: PriceSetDTO
price_set_id?: string
price_rules?: PriceRuleDTO[]
money_amount?: MoneyAmountDTO
}
@@ -30,3 +34,9 @@ export interface CreatePriceSetMoneyAmountDTO {
price_set?: PriceSetDTO | string
money_amount?: MoneyAmountDTO | string
}
export interface FilterablePriceSetMoneyAmountProps
extends BaseFilterable<FilterablePriceSetMoneyAmountProps> {
id?: string[]
price_set_id?: string[]
}
+17 -1
View File
@@ -12,12 +12,14 @@ import {
FilterableCurrencyProps,
FilterableMoneyAmountProps,
FilterablePriceRuleProps,
FilterablePriceSetMoneyAmountProps,
FilterablePriceSetMoneyAmountRulesProps,
FilterablePriceSetProps,
FilterableRuleTypeProps,
MoneyAmountDTO,
PriceRuleDTO,
PriceSetDTO,
PriceSetMoneyAmountDTO,
PriceSetMoneyAmountRulesDTO,
PricingContext,
PricingFilters,
@@ -31,9 +33,9 @@ import {
UpdateRuleTypeDTO,
} from "./common"
import { Context } from "../shared-context"
import { FindConfig } from "../common"
import { ModuleJoinerConfig } from "../modules-sdk"
import { Context } from "../shared-context"
export interface IPricingModuleService {
/**
@@ -2198,6 +2200,20 @@ export interface IPricingModuleService {
sharedContext?: Context
): Promise<[PriceSetMoneyAmountRulesDTO[], number]>
listPriceSetMoneyAmounts(
filters?: FilterablePriceSetMoneyAmountProps,
config?: FindConfig<PriceSetMoneyAmountDTO>,
sharedContext?: Context
): Promise<PriceSetMoneyAmountDTO[]>
listAndCountPriceSetMoneyAmounts(
filters?: FilterablePriceSetMoneyAmountProps,
config?: FindConfig<PriceSetMoneyAmountDTO>,
sharedContext?: Context
): Promise<[PriceSetMoneyAmountDTO[], number]>
/**
* This method is used to create new price set money amount rules. A price set money amount rule creates an association between a price set money amount and
* a rule type.
@@ -1,2 +1,3 @@
export * from "./create-products"
export * from "./update-product-variants"
export * from "./update-products"
@@ -0,0 +1,41 @@
export interface UpsertProductVariantPricesInputDTO {
id?: string
region_id?: string
currency_code?: string
amount: number
min_quantity?: number
max_quantity?: number
}
export interface UpsertProductVariantOptionInputDTO {
value: string
option_id: string
}
export interface UpdateProductVariantsInputDTO {
id: string
title?: string
sku?: string
ean?: string
upc?: string
barcode?: string
hs_code?: string
inventory_quantity?: number
allow_backorder?: boolean
manage_inventory?: boolean
weight?: number
length?: number
height?: number
width?: number
origin_country?: string
mid_code?: string
material?: string
metadata?: Record<string, unknown>
prices?: UpsertProductVariantPricesInputDTO[]
options?: UpsertProductVariantOptionInputDTO[]
}
export interface UpdateProductVariantsWorkflowInputDTO {
productVariants: UpdateProductVariantsInputDTO[]
}
+1
View File
@@ -18,6 +18,7 @@ export * from "./object-from-string-path"
export * from "./object-to-string-path"
export * from "./remote-query-object-from-string"
export * from "./remote-query-object-to-string"
export * from './remove-nullisih'
export * from "./set-metadata"
export * from "./simple-hash"
export * from "./string-to-select-relation-object"
@@ -0,0 +1,14 @@
import { isDefined } from "./is-defined"
export const removeNullish = (
obj: Record<string, unknown>
): Record<string, unknown> =>
Object.entries(obj).reduce((resultObject, [currentKey, currentValue]) => {
if (!isDefined(currentValue) || currentValue === null) {
return resultObject
}
resultObject[currentKey] = currentValue
return resultObject
}, {})
@@ -1,5 +1,5 @@
import { ModulesSdkTypes } from "@medusajs/types"
import { MedusaError } from "../common"
import { ModulesSdkTypes } from "@medusajs/types"
function getEnv(key: string, moduleName: string): string {
const value =
@@ -66,7 +66,7 @@ export function loadDatabaseConfig(
"clientUrl" | "schema" | "driverOptions" | "debug"
> {
const clientUrl = getEnv("POSTGRES_URL", moduleName)
const database = {
clientUrl,
schema: getEnv("POSTGRES_SCHEMA", moduleName) ?? "public",
@@ -1,2 +1,3 @@
export * from "./create-products"
export * as UpdateProductVariants from "./update-product-variants"
export * from "./update-products"
@@ -0,0 +1,108 @@
import {
TransactionStepsDefinition,
WorkflowManager,
} from "@medusajs/orchestration"
import { InputAlias, Workflows } from "../../definitions"
import { exportWorkflow, pipe } from "../../helper"
import { ProductTypes, WorkflowTypes } from "@medusajs/types"
import { ProductHandlers } from "../../handlers"
export enum UpdateProductVariantsActions {
prepare = "prepare",
updateProductVariants = "updateProductVariants",
revertProductVariantsUpdate = "revertProductVariantsUpdate",
upsertPrices = "upsertPrices",
}
export const workflowSteps: TransactionStepsDefinition = {
next: {
action: UpdateProductVariantsActions.prepare,
noCompensation: true,
next: {
action: UpdateProductVariantsActions.updateProductVariants,
noCompensation: true,
next: [
{
action: UpdateProductVariantsActions.upsertPrices,
},
],
},
},
}
const handlers = new Map([
[
UpdateProductVariantsActions.prepare,
{
invoke: pipe(
{
merge: true,
inputAlias: InputAlias.ProductVariantsUpdateInputData,
invoke: {
from: InputAlias.ProductVariantsUpdateInputData,
},
},
ProductHandlers.updateProductVariantsPrepareData
),
},
],
[
UpdateProductVariantsActions.updateProductVariants,
{
invoke: pipe(
{
merge: true,
invoke: {
from: UpdateProductVariantsActions.prepare,
},
},
ProductHandlers.updateProductVariants
),
},
],
[
UpdateProductVariantsActions.upsertPrices,
{
invoke: pipe(
{
merge: true,
invoke: [
{
from: UpdateProductVariantsActions.prepare,
},
],
},
ProductHandlers.upsertVariantPrices
),
compensate: pipe(
{
merge: true,
invoke: [
{
from: UpdateProductVariantsActions.prepare,
},
{
from: UpdateProductVariantsActions.upsertPrices,
},
],
},
ProductHandlers.revertVariantPrices
),
},
],
])
WorkflowManager.register(
Workflows.UpdateProductVariants,
workflowSteps,
handlers
)
export const updateProductVariants = exportWorkflow<
WorkflowTypes.ProductWorkflow.UpdateProductVariantsWorkflowInputDTO,
ProductTypes.ProductVariantDTO[]
>(
Workflows.UpdateProductVariants,
UpdateProductVariantsActions.updateProductVariants
)
@@ -1,16 +1,17 @@
import { ProductTypes, WorkflowTypes } from "@medusajs/types"
import { InputAlias, Workflows } from "../../definitions"
import {
TransactionStepsDefinition,
WorkflowManager,
} from "@medusajs/orchestration"
import { exportWorkflow, pipe } from "../../helper"
import { CreateProductsActions } from "./create-products"
import { InputAlias, Workflows } from "../../definitions"
import { InventoryHandlers, ProductHandlers } from "../../handlers"
import * as MiddlewareHandlers from "../../handlers/middlewares"
import { detachSalesChannelFromProducts } from "../../handlers/product"
import { exportWorkflow, pipe } from "../../helper"
import { CreateProductsActions } from "./create-products"
import { prepareCreateInventoryItems } from "./prepare-create-inventory-items"
import { UpdateProductVariantsActions } from "./update-product-variants"
export enum UpdateProductsActions {
prepare = "prepare",
@@ -32,6 +33,10 @@ export const updateProductsWorkflowSteps: TransactionStepsDefinition = {
next: {
action: UpdateProductsActions.updateProducts,
next: [
{
action: UpdateProductVariantsActions.upsertPrices,
saveResponse: false,
},
{
action: UpdateProductsActions.attachSalesChannels,
saveResponse: false,
@@ -59,7 +64,7 @@ export const updateProductsWorkflowSteps: TransactionStepsDefinition = {
},
}
const handlers = new Map([
const handlers = new Map<string, any>([
[
UpdateProductsActions.prepare,
{
@@ -350,6 +355,37 @@ const handlers = new Map([
),
},
],
[
UpdateProductVariantsActions.upsertPrices,
{
invoke: pipe(
{
merge: true,
invoke: [
{
from: InputAlias.ProductsInputData,
alias: ProductHandlers.updateProducts.aliases.products,
},
{
from: UpdateProductsActions.prepare,
},
],
},
ProductHandlers.upsertVariantPrices
),
compensate: pipe(
{
merge: true,
invoke: [
{
from: UpdateProductVariantsActions.upsertPrices,
},
],
},
ProductHandlers.revertVariantPrices
),
},
],
])
WorkflowManager.register(
+6
View File
@@ -3,6 +3,9 @@ export enum Workflows {
CreateProducts = "create-products",
UpdateProducts = "update-products",
// Product Variant workflows
UpdateProductVariants = "update-product-variants",
// Cart workflows
CreateCart = "create-cart",
@@ -14,6 +17,9 @@ export enum InputAlias {
ProductsInputData = "productsInputData",
RemovedProducts = "removedProducts",
ProductVariants = "productVariants",
ProductVariantsUpdateInputData = "productVariantsUpdateInputData",
InventoryItems = "inventoryItems",
RemovedInventoryItems = "removedInventoryItems",
@@ -1,8 +1,8 @@
import { ProductTypes, SalesChannelTypes, WorkflowTypes } from "@medusajs/types"
import {
FeatureFlagUtils,
kebabCase,
ShippingProfileUtils,
kebabCase,
} from "@medusajs/utils"
import { WorkflowArguments } from "../../helper"
@@ -1,12 +1,16 @@
export * from "./create-products-prepare-data"
export * from "./create-products"
export * from "./detach-sales-channel-from-products"
export * from "./attach-sales-channel-to-products"
export * from "./detach-shipping-profile-from-products"
export * from "./remove-products"
export * from "./attach-shipping-profile-to-products"
export * from "./create-products"
export * from "./create-products-prepare-data"
export * from "./detach-sales-channel-from-products"
export * from "./detach-shipping-profile-from-products"
export * from "./list-products"
export * from "./remove-products"
export * from "./revert-update-products"
export * from "./revert-variant-prices"
export * from "./update-product-variants"
export * from "./update-product-variants-prepare-data"
export * from "./update-products"
export * from "./update-products-prepare-data"
export * from "./revert-update-products"
export * from "./update-products-variants-prices"
export * from "./upsert-variant-prices"
@@ -0,0 +1,48 @@
import { PricingTypes } from "@medusajs/types"
import { WorkflowArguments } from "../../helper"
type HandlerInput = {
createdLinks: Record<any, any>[]
originalMoneyAmounts: PricingTypes.MoneyAmountDTO[]
createdPriceSets: PricingTypes.PriceSetDTO[]
}
export async function revertVariantPrices({
container,
context,
data,
}: WorkflowArguments<HandlerInput>): Promise<void> {
const {
createdLinks = [],
originalMoneyAmounts = [],
createdPriceSets = [],
} = data
const featureFlagRouter = container.resolve("featureFlagRouter")
const isPricingDomainEnabled = featureFlagRouter.isFeatureEnabled(
"isolate_pricing_domain"
)
if (!isPricingDomainEnabled) {
return
}
const pricingModuleService = container.resolve("pricingModuleService")
const remoteLink = container.resolve("remoteLink")
await remoteLink.remove(createdLinks)
if (originalMoneyAmounts.length) {
await pricingModuleService.updateMoneyAmounts(originalMoneyAmounts)
}
if (createdPriceSets.length) {
await pricingModuleService.delete({
id: createdPriceSets.map((cps) => cps.id),
})
}
}
revertVariantPrices.aliases = {
productVariantsPrices: "productVariantsPrices",
}
@@ -0,0 +1,95 @@
import { Modules, ModulesDefinition } from "@medusajs/modules-sdk"
import { ProductTypes, ProductWorkflow, WorkflowTypes } from "@medusajs/types"
import { WorkflowArguments } from "../../helper"
type VariantPrice = {
region_id?: string
currency_code?: string
amount: number
min_quantity?: number
max_quantity?: number
}
export type UpdateProductVariantsPreparedData = {
productVariants: ProductWorkflow.UpdateProductVariantsInputDTO[]
variantPricesMap: Map<string, VariantPrice[]>
productVariantsMap: Map<
string,
ProductWorkflow.UpdateProductVariantsInputDTO[]
>
}
export async function updateProductVariantsPrepareData({
container,
context,
data,
}: WorkflowArguments<WorkflowTypes.ProductWorkflow.UpdateProductVariantsWorkflowInputDTO>): Promise<UpdateProductVariantsPreparedData> {
const featureFlagRouter = container.resolve("featureFlagRouter")
const isPricingDomainEnabled = featureFlagRouter.isFeatureEnabled(
"isolate_pricing_domain"
)
let productVariants: ProductWorkflow.UpdateProductVariantsInputDTO[] =
data.productVariants || []
const variantsDataMap = new Map<
string,
ProductWorkflow.UpdateProductVariantsInputDTO
>(
productVariants.map((productVariantData) => [
productVariantData.id,
productVariantData,
])
)
const variantIds = productVariants.map((pv) => pv.id) as string[]
const productVariantsMap = new Map<
string,
ProductWorkflow.UpdateProductVariantsInputDTO[]
>()
const variantPricesMap = new Map<string, VariantPrice[]>()
const productModuleService: ProductTypes.IProductModuleService =
container.resolve(ModulesDefinition[Modules.PRODUCT].registrationName)
const variantsWithProductIds = await productModuleService.listVariants(
{
id: variantIds,
},
{
select: ["id", "product_id"],
}
)
for (const variantWithProductID of variantsWithProductIds) {
const variantData = variantsDataMap.get(variantWithProductID.id)
if (!variantData) {
continue
}
variantPricesMap.set(variantWithProductID.id, variantData.prices || [])
if (isPricingDomainEnabled) {
delete variantData.prices
}
const variantsData: ProductWorkflow.UpdateProductVariantsInputDTO[] =
productVariantsMap.get(variantWithProductID.product_id) || []
if (variantData) {
variantsData.push(variantData)
}
productVariantsMap.set(variantWithProductID.product_id, variantsData)
}
return {
productVariants,
variantPricesMap,
productVariantsMap,
}
}
updateProductVariantsPrepareData.aliases = {
payload: "payload",
}
@@ -0,0 +1,39 @@
import { Modules, ModulesDefinition } from "@medusajs/modules-sdk"
import { ProductTypes } from "@medusajs/types"
import { WorkflowArguments } from "../../helper"
type HandlerInput = {
productVariantsMap: Map<string, ProductTypes.UpdateProductVariantDTO[]>
}
export async function updateProductVariants({
container,
data,
}: WorkflowArguments<HandlerInput>): Promise<
ProductTypes.UpdateProductVariantDTO[]
> {
const { productVariantsMap } = data
const productsVariants: ProductTypes.UpdateProductVariantDTO[] = []
const updateProductsData: ProductTypes.UpdateProductDTO[] = []
const productModuleService: ProductTypes.IProductModuleService =
container.resolve(ModulesDefinition[Modules.PRODUCT].registrationName)
for (const [productId, variantsData = []] of productVariantsMap) {
updateProductsData.push({
id: productId,
variants: variantsData,
})
productsVariants.push(...variantsData)
}
if (updateProductsData.length) {
await productModuleService.update(updateProductsData)
}
return productsVariants
}
updateProductVariants.aliases = {
payload: "payload",
}
@@ -6,10 +6,19 @@ type ProductWithSalesChannelsDTO = ProductDTO & {
sales_channels?: SalesChannelDTO[]
}
type VariantPrice = {
region_id?: string
currency_code?: string
amount: number
min_quantity?: number
max_quantity?: number
}
export type UpdateProductsPreparedData = {
originalProducts: ProductWithSalesChannelsDTO[]
productHandleAddedChannelsMap: Map<string, string[]>
productHandleRemovedChannelsMap: Map<string, string[]>
variantPricesMap: Map<string, VariantPrice[]>
}
export async function updateProductsPrepareData({
@@ -17,6 +26,12 @@ export async function updateProductsPrepareData({
context,
data,
}: WorkflowArguments<WorkflowTypes.ProductWorkflow.UpdateProductsWorkflowInputDTO>): Promise<UpdateProductsPreparedData> {
const featureFlagRouter = container.resolve("featureFlagRouter")
const isPricingDomainEnabled = featureFlagRouter.isFeatureEnabled(
"isolate_pricing_domain"
)
const variantPricesMap = new Map<string, VariantPrice[]>()
const ids = data.products.map((product) => product.id)
const productHandleAddedChannelsMap = new Map<string, string[]>()
@@ -65,6 +80,16 @@ export async function updateProductsPrepareData({
})
}
for (const variantInput of productInput.variants || []) {
if (variantInput.id) {
variantPricesMap.set(variantInput.id, variantInput.prices || [])
}
if (isPricingDomainEnabled) {
delete variantInput.prices
}
}
productHandleAddedChannelsMap.set(currentProduct.handle!, addedChannels)
productHandleRemovedChannelsMap.set(currentProduct.handle!, removedChannels)
})
@@ -73,6 +98,7 @@ export async function updateProductsPrepareData({
originalProducts: products,
productHandleAddedChannelsMap,
productHandleRemovedChannelsMap,
variantPricesMap,
}
}
@@ -1,4 +1,5 @@
import { ProductTypes, WorkflowTypes } from "@medusajs/types"
import { MedusaError } from "@medusajs/utils"
import { WorkflowArguments } from "../../helper"
@@ -26,9 +27,12 @@ export async function updateProductsVariantsPrices({
data.productsHandleVariantsIndexPricesMap
const productVariantService = container.resolve("productVariantService")
const regionService = container.resolve("regionService")
const featureFlagRouter = container.resolve("featureFlagRouter")
const productVariantServiceTx = productVariantService.withTransaction(manager)
const variantIdsPricesData: any[] = []
const variantPricesMap = new Map<string, any[]>()
const productsMap = new Map<string, ProductTypes.ProductDTO>(
products.map((p) => [p.handle!, p])
)
@@ -50,10 +54,52 @@ export async function updateProductsVariantsPrices({
variantId: variant.id,
prices: item.prices,
})
variantPricesMap.set(variant.id, [])
item.prices.forEach(async (price) => {
const obj = {
amount: price.amount,
currency_code: price.currency_code,
rules: {},
}
if (price.region_id) {
const region = await regionService.retrieve(price.region_id)
obj.currency_code = region.currency_code
obj.rules = {
region_id: price.region_id,
}
}
const variantPrices = variantPricesMap.get(variant.id)
variantPrices?.push(obj)
})
})
}
await productVariantServiceTx.updateVariantPrices(variantIdsPricesData)
if (featureFlagRouter.isFeatureEnabled("isolate_pricing_domain")) {
const remoteLink = container.resolve("remoteLink")
const pricingModuleService = container.resolve("pricingModuleService")
for (let { variantId } of variantIdsPricesData) {
const priceSet = await pricingModuleService.create({
rules: [{ rule_attribute: "region_id" }],
prices: variantPricesMap.get(variantId),
})
await remoteLink.create({
productService: {
variant_id: variantId,
},
pricingService: {
price_set_id: priceSet.id,
},
})
}
} else {
await productVariantServiceTx.updateVariantPrices(variantIdsPricesData)
}
}
updateProductsVariantsPrices.aliases = {
@@ -0,0 +1,168 @@
import { PricingTypes } from "@medusajs/types"
import { WorkflowArguments } from "../../helper"
type VariantPrice = {
id?: string
region_id?: string
currency_code: string
amount: number
min_quantity?: number
max_quantity?: number
rules: Record<string, string>
}
type RegionDTO = {
id: string
currency_code: string
}
type HandlerInput = {
variantPricesMap: Map<string, VariantPrice[]>
}
export async function upsertVariantPrices({
container,
context,
data,
}: WorkflowArguments<HandlerInput>) {
const { variantPricesMap } = data
const featureFlagRouter = container.resolve("featureFlagRouter")
if (!featureFlagRouter.isFeatureEnabled("isolate_pricing_domain")) {
return {
createdLinks: [],
originalMoneyAmounts: [],
createdPriceSets: [],
}
}
const pricingModuleService = container.resolve("pricingModuleService")
const regionService = container.resolve("regionService")
const remoteLink = container.resolve("remoteLink")
const remoteQuery = container.resolve("remoteQuery")
const variables = {
variant_id: [...variantPricesMap.keys()],
}
const query = {
product_variant_price_set: {
__args: variables,
fields: ["variant_id", "price_set_id"],
},
}
const variantPriceSets = await remoteQuery(query)
const variantIdToPriceSetIdMap: Map<string, string> = new Map(
variantPriceSets.map((variantPriceSet) => [
variantPriceSet.variant_id,
variantPriceSet.price_set_id,
])
)
const moneyAmountsToUpdate: PricingTypes.UpdateMoneyAmountDTO[] = []
const createdPriceSets: PricingTypes.PriceSetDTO[] = []
const ruleSetPricesToAdd: PricingTypes.CreatePricesDTO[] = []
const linksToCreate: any[] = []
for (const [variantId, prices = []] of variantPricesMap) {
const priceSetToCreate: PricingTypes.CreatePriceSetDTO = {
rules: [{ rule_attribute: "region_id" }],
prices: [],
}
const regionIds = prices.map((price) => price.region_id)
const regions = await regionService.list({ id: regionIds })
const regionsMap: Map<string, RegionDTO> = new Map(
regions.map((region: RegionDTO) => [region.id, region])
)
for (const price of prices) {
if (price.id) {
moneyAmountsToUpdate.push({
id: price.id,
min_quantity: price.min_quantity,
max_quantity: price.max_quantity,
amount: price.amount,
currency_code: price.currency_code,
})
} else {
const region = price.region_id && regionsMap.get(price.region_id)
const variantPrice: PricingTypes.CreatePricesDTO = {
min_quantity: price.min_quantity,
max_quantity: price.max_quantity,
amount: price.amount,
currency_code: price.currency_code,
rules: {},
}
if (region) {
variantPrice.currency_code = region.currency_code
variantPrice.rules = {
region_id: region.id,
}
}
delete price.region_id
if (variantIdToPriceSetIdMap.get(variantId)) {
ruleSetPricesToAdd.push(variantPrice)
} else {
priceSetToCreate.prices?.push(variantPrice)
}
}
}
let priceSetId = variantIdToPriceSetIdMap.get(variantId)
if (priceSetId) {
await pricingModuleService.addPrices({
priceSetId,
prices: ruleSetPricesToAdd,
})
} else {
const createdPriceSet = await pricingModuleService.create(
priceSetToCreate
)
priceSetId = createdPriceSet?.id
createdPriceSets.push(createdPriceSet)
}
linksToCreate.push({
productService: {
variant_id: variantId,
},
pricingService: {
price_set_id: priceSetId,
},
})
}
const createdLinks = await remoteLink.create(linksToCreate)
let originalMoneyAmounts = await pricingModuleService.listMoneyAmounts(
{
id: moneyAmountsToUpdate.map((matu) => matu.id),
},
{
select: ["id", "currency_code", "amount", "min_quantity", "max_quantity"],
}
)
if (moneyAmountsToUpdate.length) {
await pricingModuleService.updateMoneyAmounts(moneyAmountsToUpdate)
}
return {
createdLinks,
originalMoneyAmounts,
createdPriceSets,
}
}
upsertVariantPrices.aliases = {
productVariantsPrices: "productVariantsPrices",
}
+4 -2
View File
@@ -6705,7 +6705,7 @@ __metadata:
languageName: unknown
linkType: soft
"@medusajs/modules-sdk@^1.12.0, @medusajs/modules-sdk@^1.12.1, @medusajs/modules-sdk@^1.8.8, @medusajs/modules-sdk@workspace:packages/modules-sdk":
"@medusajs/modules-sdk@^1.12.0, @medusajs/modules-sdk@^1.12.1, @medusajs/modules-sdk@^1.8.8, @medusajs/modules-sdk@workspace:^, @medusajs/modules-sdk@workspace:packages/modules-sdk":
version: 0.0.0-use.local
resolution: "@medusajs/modules-sdk@workspace:packages/modules-sdk"
dependencies:
@@ -6788,7 +6788,7 @@ __metadata:
languageName: unknown
linkType: soft
"@medusajs/pricing@workspace:packages/pricing":
"@medusajs/pricing@workspace:^, @medusajs/pricing@workspace:packages/pricing":
version: 0.0.0-use.local
resolution: "@medusajs/pricing@workspace:packages/pricing"
dependencies:
@@ -26517,6 +26517,8 @@ __metadata:
"@medusajs/event-bus-local": "workspace:*"
"@medusajs/inventory": "workspace:^"
"@medusajs/medusa": "workspace:*"
"@medusajs/modules-sdk": "workspace:^"
"@medusajs/pricing": "workspace:^"
"@medusajs/product": "workspace:^"
babel-preset-medusa-package: "*"
faker: ^5.5.3