feat(medusa): Performance improvements of Carts domain (#2648)

**What**

I have created a new method on the cart service which is `addLineItems`, allowing a user to add one or multiple items in an optimized way. Also updated the `generate` method from the line item service which now also accept a object data or a collection of data which. Various places have been optimized and cache support has been added to the price selection strategy.

The overall optimization allows to reach another 9000% improvement in the response time as a median (Creating a cart with 6 items):

|   | Min (ms)  | Median (ms)  | Max (ms)  | Median Improvement (%)
|---|:-:|---|---|---|
| Before optimisation  | 1200  | 9999 | 12698  |  N/A
| After optimisation | 63  | 252  | 500  | 39x
| After re optimisation | 56 | 82  | 399  | 121x
| After including addressed feedback | 65 | 202  | 495  | 49x

FIXES CORE-722
This commit is contained in:
Adrien de Peretti authored and GitHub committed 2022-12-07 14:39:12 +00:00
1 parent 3b2c929408
commit 42d9c7222b
71 files changed
+1204 -439

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
Feat(medusa): Optimize the cart creation with time line and therefore the response time
@@ -29,7 +29,7 @@ describe("/admin/collections", () => {
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: true }) medusaProcess = await setupServer({ cwd })
}) })
afterAll(async () => { afterAll(async () => {
@@ -26,7 +26,7 @@ describe("/admin/draft-orders", () => {
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: true }) medusaProcess = await setupServer({ cwd })
}) })
afterAll(async () => { afterAll(async () => {
@@ -29,7 +29,6 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/draft-orders", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true }, env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
@@ -40,7 +40,6 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_ORDER_EDITING: true }, env: { MEDUSA_FF_ORDER_EDITING: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -10,8 +10,8 @@ const adminSeeder = require("../../../helpers/admin-seeder")
const { const {
simpleRegionFactory, simpleRegionFactory,
simpleShippingOptionFactory, simpleShippingOptionFactory,
simpleOrderFactory simpleOrderFactory,
} = require("../../../factories"); } = require("../../../factories")
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -24,7 +24,6 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/orders", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true }, env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -50,7 +49,7 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/orders", () => {
country_code: "us", country_code: "us",
} }
const region = await simpleRegionFactory(dbConnection, { const region = await simpleRegionFactory(dbConnection, {
id: "test-region" id: "test-region",
}) })
order = await simpleOrderFactory(dbConnection, { order = await simpleOrderFactory(dbConnection, {
id: "test-order", id: "test-order",
@@ -58,16 +57,19 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/orders", () => {
shipping_address: shippingAddress, shipping_address: shippingAddress,
currency_code: "usd", currency_code: "usd",
}) })
includesTaxShippingOption = await simpleShippingOptionFactory(dbConnection, { includesTaxShippingOption = await simpleShippingOptionFactory(
includes_tax: true, dbConnection,
region_id: region.id {
}) includes_tax: true,
region_id: region.id,
}
)
} catch (err) { } catch (err) {
console.log(err) console.log(err)
} }
}) })
afterEach(async() => { afterEach(async () => {
const db = useDb() const db = useDb()
return await db.teardown() return await db.teardown()
}) })
@@ -76,26 +78,27 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/orders", () => {
const api = useApi() const api = useApi()
const orderWithShippingMethodRes = await api.post( const orderWithShippingMethodRes = await api.post(
`/admin/orders/${order.id}/shipping-methods`, `/admin/orders/${order.id}/shipping-methods`,
{ {
option_id: includesTaxShippingOption.id, option_id: includesTaxShippingOption.id,
price: 10, price: 10,
},
{
headers: {
Authorization: "Bearer test_token",
}, },
{ }
headers: {
Authorization: "Bearer test_token",
},
}
) )
expect(orderWithShippingMethodRes.status).toEqual(200) expect(orderWithShippingMethodRes.status).toEqual(200)
expect(orderWithShippingMethodRes.data.order.shipping_methods) expect(orderWithShippingMethodRes.data.order.shipping_methods).toEqual(
.toEqual(expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
shipping_option_id: includesTaxShippingOption.id, shipping_option_id: includesTaxShippingOption.id,
includes_tax: true, includes_tax: true,
}) }),
])) ])
)
}) })
}) })
}) })
@@ -33,7 +33,7 @@ describe("/admin/orders", () => {
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: false }) medusaProcess = await setupServer({ cwd })
}) })
afterAll(async () => { afterAll(async () => {
@@ -28,7 +28,6 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/payment-collections", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_ORDER_EDITING: true }, env: { MEDUSA_FF_ORDER_EDITING: true },
verbose: true,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -31,7 +31,6 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/payment", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_ORDER_EDITING: true }, env: { MEDUSA_FF_ORDER_EDITING: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -1415,7 +1415,6 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/price-lists", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true }, env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -36,7 +36,9 @@ describe("/admin/products", () => {
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: false }) medusaProcess = await setupServer({
cwd,
})
}) })
afterAll(async () => { afterAll(async () => {
@@ -43,7 +43,6 @@ describe("[MEDUSA_FF_PUBLISHABLE_API_KEYS] Publishable API keys", () => {
MEDUSA_FF_PUBLISHABLE_API_KEYS: true, MEDUSA_FF_PUBLISHABLE_API_KEYS: true,
MEDUSA_FF_SALES_CHANNELS: true, MEDUSA_FF_SALES_CHANNELS: true,
}, },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
+17 -15
View File
@@ -2,11 +2,12 @@ const path = require("path")
const { Region } = require("@medusajs/medusa") const { Region } = require("@medusajs/medusa")
const setupServer = require("../../../helpers/setup-server") const setupServer = require("../../../helpers/setup-server")
const startServerWithEnvironment = require("../../../helpers/start-server-with-environment").default const startServerWithEnvironment =
require("../../../helpers/start-server-with-environment").default
const { useApi } = require("../../../helpers/use-api") const { useApi } = require("../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db") const { initDb, useDb } = require("../../../helpers/use-db")
const adminSeeder = require("../../helpers/admin-seeder") const adminSeeder = require("../../helpers/admin-seeder")
const { simpleRegionFactory } = require("../../factories"); const { simpleRegionFactory } = require("../../factories")
const adminReqConfig = { const adminReqConfig = {
headers: { headers: {
@@ -304,7 +305,6 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/regions", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true }, env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -364,7 +364,7 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/regions", () => {
name: "region-including-taxes", name: "region-including-taxes",
}) })
) )
}); })
it("should allow to update a region that includes tax", async function () { it("should allow to update a region that includes tax", async function () {
const api = useApi() const api = useApi()
@@ -376,17 +376,19 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/regions", () => {
expect(response.data.region.includes_tax).toBe(false) expect(response.data.region.includes_tax).toBe(false)
response = await api.post( response = await api
`/admin/regions/${region1TaxInclusiveId}`, .post(
{ `/admin/regions/${region1TaxInclusiveId}`,
includes_tax: true, {
}, includes_tax: true,
adminReqConfig, },
).catch((err) => { adminReqConfig
console.log(err) )
}) .catch((err) => {
console.log(err)
})
expect(response.data.region.includes_tax).toBe(true) expect(response.data.region.includes_tax).toBe(true)
}); })
}) })
}) })
@@ -15,7 +15,7 @@ describe("/admin/return-reasons", () => {
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: false }) medusaProcess = await setupServer({ cwd })
}) })
afterAll(async () => { afterAll(async () => {
@@ -34,7 +34,6 @@ describe("sales channels", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_SALES_CHANNELS: true }, env: { MEDUSA_FF_SALES_CHANNELS: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -1,15 +1,17 @@
const path = require("path") const path = require("path")
const { const { ShippingProfile } = require("@medusajs/medusa")
ShippingProfile,
} = require("@medusajs/medusa")
const setupServer = require("../../../helpers/setup-server") const setupServer = require("../../../helpers/setup-server")
const startServerWithEnvironment = require("../../../helpers/start-server-with-environment").default const startServerWithEnvironment =
require("../../../helpers/start-server-with-environment").default
const { useApi } = require("../../../helpers/use-api") const { useApi } = require("../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db") const { initDb, useDb } = require("../../../helpers/use-db")
const adminSeeder = require("../../helpers/admin-seeder") const adminSeeder = require("../../helpers/admin-seeder")
const shippingOptionSeeder = require("../../helpers/shipping-option-seeder") const shippingOptionSeeder = require("../../helpers/shipping-option-seeder")
const { simpleShippingOptionFactory, simpleRegionFactory } = require("../../factories") const {
simpleShippingOptionFactory,
simpleRegionFactory,
} = require("../../factories")
const adminReqConfig = { const adminReqConfig = {
headers: { headers: {
@@ -475,7 +477,6 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/shipping-options", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true }, env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -517,9 +518,12 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/shipping-options", () => {
it("should creates a shipping option that includes tax", async () => { it("should creates a shipping option that includes tax", async () => {
const api = useApi() const api = useApi()
const defaultProfile = await dbConnection.manager.findOne(ShippingProfile, { const defaultProfile = await dbConnection.manager.findOne(
type: "default", ShippingProfile,
}) {
type: "default",
}
)
const payload = { const payload = {
name: "Test option", name: "Test option",
@@ -551,7 +555,10 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/shipping-options", () => {
const api = useApi() const api = useApi()
let response = await api let response = await api
.get(`/admin/shipping-options/${shippingOptionIncludesTaxId}`, adminReqConfig) .get(
`/admin/shipping-options/${shippingOptionIncludesTaxId}`,
adminReqConfig
)
.catch((err) => { .catch((err) => {
console.log(err) console.log(err)
}) })
@@ -573,7 +580,11 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/shipping-options", () => {
} }
response = await api response = await api
.post(`/admin/shipping-options/${shippingOptionIncludesTaxId}`, payload, adminReqConfig) .post(
`/admin/shipping-options/${shippingOptionIncludesTaxId}`,
payload,
adminReqConfig
)
.catch((err) => { .catch((err) => {
console.log(err) console.log(err)
}) })
@@ -363,7 +363,6 @@ describe("/admin/swaps", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true }, env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -28,7 +28,7 @@ describe("/admin/users", () => {
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: false }) medusaProcess = await setupServer({ cwd })
}) })
afterAll(async () => { afterAll(async () => {
@@ -1,6 +1,6 @@
const path = require("path") const path = require("path")
const fs = require("fs/promises") const fs = require("fs/promises")
import { sep, resolve } from "path" import { resolve, sep } from "path"
const setupServer = require("../../../../helpers/setup-server") const setupServer = require("../../../../helpers/setup-server")
const { useApi } = require("../../../../helpers/use-api") const { useApi } = require("../../../../helpers/use-api")
@@ -31,7 +31,6 @@ describe("Batchjob with type order-export", () => {
cwd, cwd,
redisUrl: "redis://127.0.0.1:6379", redisUrl: "redis://127.0.0.1:6379",
uploadDir: __dirname, uploadDir: __dirname,
verbose: false,
}) })
}) })
@@ -66,7 +66,6 @@ describe("Price list import batch job", () => {
cwd, cwd,
redisUrl: "redis://127.0.0.1:6379", redisUrl: "redis://127.0.0.1:6379",
uploadDir: __dirname, uploadDir: __dirname,
verbose: false,
}) })
}) })
@@ -16,7 +16,7 @@ const adminReqConfig = {
}, },
} }
jest.setTimeout(100000000) jest.setTimeout(180000)
describe("Batch job of product-export type", () => { describe("Batch job of product-export type", () => {
let medusaProcess let medusaProcess
@@ -31,7 +31,6 @@ describe("Batch job of product-export type", () => {
cwd, cwd,
redisUrl: "redis://127.0.0.1:6379", redisUrl: "redis://127.0.0.1:6379",
uploadDir: __dirname, uploadDir: __dirname,
verbose: false,
}) })
}) })
@@ -61,7 +61,6 @@ describe("Product import - Sales Channel", () => {
env: { MEDUSA_FF_SALES_CHANNELS: true }, env: { MEDUSA_FF_SALES_CHANNELS: true },
redisUrl: "redis://127.0.0.1:6379", redisUrl: "redis://127.0.0.1:6379",
uploadDir: __dirname, uploadDir: __dirname,
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -59,7 +59,6 @@ describe("Product import batch job", () => {
cwd, cwd,
redisUrl: "redis://127.0.0.1:6379", redisUrl: "redis://127.0.0.1:6379",
uploadDir: __dirname, uploadDir: __dirname,
verbose: false,
}) })
}) })
@@ -23,7 +23,6 @@ describe("Line Item - Sales Channel", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_SALES_CHANNELS: true }, env: { MEDUSA_FF_SALES_CHANNELS: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -28,7 +28,6 @@ describe("tax inclusive prices", () => {
const [process, conn] = await startServerWithEnvironment({ const [process, conn] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true }, env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
}) })
dbConnection = conn // await initDb({ cwd }) dbConnection = conn // await initDb({ cwd })
medusaProcess = process // await setupServer({ cwd }) medusaProcess = process // await setupServer({ cwd })
@@ -74,7 +74,6 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /store/carts", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true }, env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -47,7 +47,7 @@ describe("/store/carts", () => {
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: false }) medusaProcess = await setupServer({ cwd })
}) })
afterAll(async () => { afterAll(async () => {
@@ -28,7 +28,6 @@ describe("[MEDUSA_FF_SALES_CHANNELS] /store/carts", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_SALES_CHANNELS: true }, env: { MEDUSA_FF_SALES_CHANNELS: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -27,7 +27,6 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /store/carts", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true }, env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -261,6 +260,25 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /store/carts", () => {
}) })
describe("with a cart with full tax inclusive variant pricing", () => { describe("with a cart with full tax inclusive variant pricing", () => {
const variantId1 = IdMap.getId("test-variant-1-tax-inclusive")
const variantId2 = IdMap.getId("test-variant-2-tax-inclusive")
const productId1 = IdMap.getId("test-product-1-tax-inclusive")
const productId2 = IdMap.getId("test-product-2-tax-inclusive")
const createCartPayload = {
region_id: regionId,
items: [
{
variant_id: variantId1,
quantity: 1,
},
{
variant_id: variantId2,
quantity: 1,
},
],
}
beforeEach(async () => { beforeEach(async () => {
await simpleRegionFactory(dbConnection, regionData) await simpleRegionFactory(dbConnection, regionData)
await simpleProductFactory( await simpleProductFactory(
@@ -319,6 +337,25 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /store/carts", () => {
}) })
describe("with a cart mixing tax inclusive and exclusive variant pricing", () => { describe("with a cart mixing tax inclusive and exclusive variant pricing", () => {
const variantId1 = IdMap.getId("test-variant-1-mixed-tax-inclusive")
const variantId2 = IdMap.getId("test-variant-2-mixed-tax-inclusive")
const productId1 = IdMap.getId("test-product-1-mixed-tax-inclusive")
const productId2 = IdMap.getId("test-product-2-mixed-tax-inclusive")
const createCartPayload = {
region_id: regionId,
items: [
{
variant_id: variantId1,
quantity: 1,
},
{
variant_id: variantId2,
quantity: 1,
},
],
}
beforeEach(async () => { beforeEach(async () => {
await simpleRegionFactory(dbConnection, regionData) await simpleRegionFactory(dbConnection, regionData)
await simpleProductFactory( await simpleProductFactory(
@@ -14,7 +14,7 @@ describe("/store/collections", () => {
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: true }) medusaProcess = await setupServer({ cwd })
}) })
afterAll(async () => { afterAll(async () => {
@@ -19,7 +19,7 @@ describe("/store/customers", () => {
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: false }) medusaProcess = await setupServer({ cwd })
}) })
afterAll(async () => { afterAll(async () => {
@@ -30,7 +30,6 @@ describe("[MEDUSA_FF_ORDER_EDITING] /store/order-edits", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_ORDER_EDITING: true }, env: { MEDUSA_FF_ORDER_EDITING: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -31,7 +31,7 @@ describe("/store/carts", () => {
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: false }) medusaProcess = await setupServer({ cwd })
}) })
afterAll(async () => { afterAll(async () => {
@@ -25,7 +25,6 @@ describe("[MEDUSA_FF_ORDER_EDITING] /store/payment-collections", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_ORDER_EDITING: true }, env: { MEDUSA_FF_ORDER_EDITING: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -15,7 +15,7 @@ describe("/store/variants", () => {
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: false }) medusaProcess = await setupServer({ cwd })
}) })
afterAll(async () => { afterAll(async () => {
@@ -31,7 +31,6 @@ describe("sales channels", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_SALES_CHANNELS: true }, env: { MEDUSA_FF_SALES_CHANNELS: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -28,7 +28,7 @@ describe("Automatic Cart Taxes", () => {
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: false }) medusaProcess = await setupServer({ cwd })
}) })
afterAll(async () => { afterAll(async () => {
@@ -25,7 +25,7 @@ describe("Manual Cart Taxes", () => {
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: false }) medusaProcess = await setupServer({ cwd })
}) })
afterAll(async () => { afterAll(async () => {
@@ -22,7 +22,6 @@ describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING]: Order Taxes", () => {
const [process, connection] = await startServerWithEnvironment({ const [process, connection] = await startServerWithEnvironment({
cwd, cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true }, env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
}) })
dbConnection = connection dbConnection = connection
medusaProcess = process medusaProcess = process
@@ -1,8 +1,9 @@
import { import {
PriceList, CustomerGroup,
MoneyAmount, MoneyAmount,
PriceListType, PriceList,
PriceListStatus, PriceListStatus,
PriceListType,
} from "@medusajs/medusa" } from "@medusajs/medusa"
import faker from "faker" import faker from "faker"
import { Connection } from "typeorm" import { Connection } from "typeorm"
@@ -41,7 +42,7 @@ export const simplePriceListFactory = async (
const listId = data.id || `simple-price-list-${Math.random() * 1000}` const listId = data.id || `simple-price-list-${Math.random() * 1000}`
let customerGroups = [] let customerGroups: CustomerGroup[] = []
if (typeof data.customer_groups !== "undefined") { if (typeof data.customer_groups !== "undefined") {
customerGroups = await Promise.all( customerGroups = await Promise.all(
data.customer_groups.map((group) => data.customer_groups.map((group) =>
+3 -3
View File
@@ -8,16 +8,16 @@
"build": "babel src -d dist --extensions \".ts,.js\"" "build": "babel src -d dist --extensions \".ts,.js\""
}, },
"dependencies": { "dependencies": {
"@medusajs/medusa": "1.6.5-dev-1669708431707", "@medusajs/medusa": "1.6.5-dev-1670340951307",
"faker": "^5.5.3", "faker": "^5.5.3",
"medusa-interfaces": "1.3.3-dev-1669708431707", "medusa-interfaces": "1.3.3-dev-1670340951307",
"typeorm": "^0.2.31" "typeorm": "^0.2.31"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.12.10", "@babel/cli": "^7.12.10",
"@babel/core": "^7.12.10", "@babel/core": "^7.12.10",
"@babel/node": "^7.12.10", "@babel/node": "^7.12.10",
"babel-preset-medusa-package": "1.1.19-dev-1669708431707", "babel-preset-medusa-package": "1.1.19-dev-1670340951307",
"jest": "^26.6.3" "jest": "^26.6.3"
} }
} }
+37 -37
View File
@@ -1775,9 +1775,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@medusajs/medusa-cli@npm:1.3.5-dev-1669708431707": "@medusajs/medusa-cli@npm:1.3.5-dev-1670340951307":
version: 1.3.5-dev-1669708431707 version: 1.3.5-dev-1670340951307
resolution: "@medusajs/medusa-cli@npm:1.3.5-dev-1669708431707" resolution: "@medusajs/medusa-cli@npm:1.3.5-dev-1670340951307"
dependencies: dependencies:
"@babel/polyfill": ^7.8.7 "@babel/polyfill": ^7.8.7
"@babel/runtime": ^7.9.6 "@babel/runtime": ^7.9.6
@@ -1793,8 +1793,8 @@ __metadata:
inquirer: ^8.0.0 inquirer: ^8.0.0
is-valid-path: ^0.1.1 is-valid-path: ^0.1.1
meant: ^1.0.1 meant: ^1.0.1
medusa-core-utils: 1.1.35-dev-1669708431707 medusa-core-utils: 1.1.35-dev-1670340951307
medusa-telemetry: 0.0.15-dev-1669708431707 medusa-telemetry: 0.0.15-dev-1670340951307
netrc-parser: ^3.1.6 netrc-parser: ^3.1.6
open: ^8.0.6 open: ^8.0.6
ora: ^5.4.1 ora: ^5.4.1
@@ -1809,15 +1809,15 @@ __metadata:
yargs: ^15.3.1 yargs: ^15.3.1
bin: bin:
medusa: cli.js medusa: cli.js
checksum: 779d98b21775542534466272d8fe415620024846aba435b45ae9956eab3fc7c627f85540ac0ac7a5282a0ab15d2c04c2dd99e72ca071b46f55da20f34effb69a checksum: 7588d1b5ef91b720c36379db278da61b308403f30807d4c470418f1072203238b2820ce14d8b8ddf154b7f61f6ae79afc081ad9e481311e83cfc44546026ba01
languageName: node languageName: node
linkType: hard linkType: hard
"@medusajs/medusa@npm:1.6.5-dev-1669708431707": "@medusajs/medusa@npm:1.6.5-dev-1670340951307":
version: 1.6.5-dev-1669708431707 version: 1.6.5-dev-1670340951307
resolution: "@medusajs/medusa@npm:1.6.5-dev-1669708431707" resolution: "@medusajs/medusa@npm:1.6.5-dev-1670340951307"
dependencies: dependencies:
"@medusajs/medusa-cli": 1.3.5-dev-1669708431707 "@medusajs/medusa-cli": 1.3.5-dev-1670340951307
"@types/ioredis": ^4.28.10 "@types/ioredis": ^4.28.10
"@types/lodash": ^4.14.168 "@types/lodash": ^4.14.168
awilix: ^8.0.0 awilix: ^8.0.0
@@ -1839,8 +1839,8 @@ __metadata:
ioredis-mock: ^5.6.0 ioredis-mock: ^5.6.0
iso8601-duration: ^1.3.0 iso8601-duration: ^1.3.0
jsonwebtoken: ^8.5.1 jsonwebtoken: ^8.5.1
medusa-core-utils: 1.1.35-dev-1669708431707 medusa-core-utils: 1.1.35-dev-1670340951307
medusa-test-utils: 1.1.37-dev-1669708431707 medusa-test-utils: 1.1.37-dev-1670340951307
morgan: ^1.9.1 morgan: ^1.9.1
multer: ^1.4.2 multer: ^1.4.2
node-schedule: ^2.1.0 node-schedule: ^2.1.0
@@ -1865,7 +1865,7 @@ __metadata:
typeorm: 0.2.x typeorm: 0.2.x
bin: bin:
medusa: cli.js medusa: cli.js
checksum: 7a7ec5ba7971112e74652791cff5eb8bfde640158618b300289d67bd753859c8312256fb2aa93f3523d2a4399f6d8b6c106e03e253f9a9518405b1224043299d checksum: 274637b6e19db96f5cc561837b254bf2f5a1fea62ecc5258739413b48e568d7b3bdd0d486b32009d7e3ee27d684bbd3451943e6a42d092624882331139befee3
languageName: node languageName: node
linkType: hard linkType: hard
@@ -2446,11 +2446,11 @@ __metadata:
"@babel/cli": ^7.12.10 "@babel/cli": ^7.12.10
"@babel/core": ^7.12.10 "@babel/core": ^7.12.10
"@babel/node": ^7.12.10 "@babel/node": ^7.12.10
"@medusajs/medusa": 1.6.5-dev-1669708431707 "@medusajs/medusa": 1.6.5-dev-1670340951307
babel-preset-medusa-package: 1.1.19-dev-1669708431707 babel-preset-medusa-package: 1.1.19-dev-1670340951307
faker: ^5.5.3 faker: ^5.5.3
jest: ^26.6.3 jest: ^26.6.3
medusa-interfaces: 1.3.3-dev-1669708431707 medusa-interfaces: 1.3.3-dev-1670340951307
typeorm: ^0.2.31 typeorm: ^0.2.31
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@@ -2757,9 +2757,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"babel-preset-medusa-package@npm:1.1.19-dev-1669708431707": "babel-preset-medusa-package@npm:1.1.19-dev-1670340951307":
version: 1.1.19-dev-1669708431707 version: 1.1.19-dev-1670340951307
resolution: "babel-preset-medusa-package@npm:1.1.19-dev-1669708431707" resolution: "babel-preset-medusa-package@npm:1.1.19-dev-1670340951307"
dependencies: dependencies:
"@babel/plugin-proposal-class-properties": ^7.12.1 "@babel/plugin-proposal-class-properties": ^7.12.1
"@babel/plugin-proposal-decorators": ^7.12.1 "@babel/plugin-proposal-decorators": ^7.12.1
@@ -2773,7 +2773,7 @@ __metadata:
core-js: ^3.7.0 core-js: ^3.7.0
peerDependencies: peerDependencies:
"@babel/core": ^7.11.6 "@babel/core": ^7.11.6
checksum: 2b01b0754da0a4bec26abcb6c94d91d7c2fd06bf9d58c23dac9266dc8c7cb470a6a8874d1564af84b068684d34028fb0288c7eae5f271a16cd1570ccaf1aa413 checksum: b091e66d23c22e26a55f8039810f73910a3860af5838904aada35e6066d3d1f9c85e5897e2879388452fd424663e676a4e19ad960167a1d001105e2d797715bc
languageName: node languageName: node
linkType: hard linkType: hard
@@ -6919,29 +6919,29 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"medusa-core-utils@npm:1.1.35-dev-1669708431707": "medusa-core-utils@npm:1.1.35-dev-1670340951307":
version: 1.1.35-dev-1669708431707 version: 1.1.35-dev-1670340951307
resolution: "medusa-core-utils@npm:1.1.35-dev-1669708431707" resolution: "medusa-core-utils@npm:1.1.35-dev-1670340951307"
dependencies: dependencies:
joi: ^17.3.0 joi: ^17.3.0
joi-objectid: ^3.0.1 joi-objectid: ^3.0.1
checksum: ac797ee8b9a165a6e90e11fbe9312bcfcaaa4271a9ef79b2cb659b053697cbee80580b3aae9bead7e2b738a864df30f150b01d9598fceb8262d6d11496a68ab4 checksum: 9304c0426dec41f33973dd122491bd3025a65eabc94616de7f63b492b558e71234fc0e02af3a13cea014b8a4bead91053e0416e107cc0b74657af9782d5c6023
languageName: node languageName: node
linkType: hard linkType: hard
"medusa-interfaces@npm:1.3.3-dev-1669708431707": "medusa-interfaces@npm:1.3.3-dev-1670340951307":
version: 1.3.3-dev-1669708431707 version: 1.3.3-dev-1670340951307
resolution: "medusa-interfaces@npm:1.3.3-dev-1669708431707" resolution: "medusa-interfaces@npm:1.3.3-dev-1670340951307"
peerDependencies: peerDependencies:
medusa-core-utils: ^1.1.31 medusa-core-utils: ^1.1.31
typeorm: 0.x typeorm: 0.x
checksum: edad068df3783072f178cac3adfa646e8886a55bf07409addec4ab18eab8f8e09e9d5ac34c1e06c65cd111330f003325c72f9dc8585348d20382a1dacf3d3536 checksum: 9eda49c0cb3922f3e1109ad231389b54ad49f730444108fd10876beab9f7845c47b965dcfe32e7169a9a4845edd143cfad96187c5a5814cbd05f45852d56c130
languageName: node languageName: node
linkType: hard linkType: hard
"medusa-telemetry@npm:0.0.15-dev-1669708431707": "medusa-telemetry@npm:0.0.15-dev-1670340951307":
version: 0.0.15-dev-1669708431707 version: 0.0.15-dev-1670340951307
resolution: "medusa-telemetry@npm:0.0.15-dev-1669708431707" resolution: "medusa-telemetry@npm:0.0.15-dev-1670340951307"
dependencies: dependencies:
axios: ^0.21.1 axios: ^0.21.1
axios-retry: ^3.1.9 axios-retry: ^3.1.9
@@ -6952,18 +6952,18 @@ __metadata:
is-docker: ^2.2.1 is-docker: ^2.2.1
remove-trailing-slash: ^0.1.1 remove-trailing-slash: ^0.1.1
uuid: ^8.3.2 uuid: ^8.3.2
checksum: 0116c6d4d70811290ba423868cbd5fc8600cf66c81942c0fb69eab41910e783f6f90b8d401e95f2847e4aa0fc74dbcd5115e30cd9758be2f01b4577d934fcb2c checksum: 15f3b7d1d639b3024d964792455a13f820fc596d7f01583f44de9803d44f129e39b201a00dab94dc43b67f31b4852835e96fd58363b55a71d6979856be58df75
languageName: node languageName: node
linkType: hard linkType: hard
"medusa-test-utils@npm:1.1.37-dev-1669708431707": "medusa-test-utils@npm:1.1.37-dev-1670340951307":
version: 1.1.37-dev-1669708431707 version: 1.1.37-dev-1670340951307
resolution: "medusa-test-utils@npm:1.1.37-dev-1669708431707" resolution: "medusa-test-utils@npm:1.1.37-dev-1670340951307"
dependencies: dependencies:
"@babel/plugin-transform-classes": ^7.9.5 "@babel/plugin-transform-classes": ^7.9.5
medusa-core-utils: 1.1.35-dev-1669708431707 medusa-core-utils: 1.1.35-dev-1670340951307
randomatic: ^3.1.1 randomatic: ^3.1.1
checksum: b89c99be68369aae6f72c395eaec11f06c64415ff6b1e9a8616fd2e14e68a1f3cfb58e7722f48057c0da7da5d1dcb260ecaa49bd89c241a55d38767b2307600b checksum: 8e96932f28e402c9561f6ea6c13a5d5ac8b5b83dc38a5e41d4ad5bcac73aa35886fe3b6d55809692594b468fc7be6e89477b6cd3d694be23f6a5abac7cb61c62
languageName: node languageName: node
linkType: hard linkType: hard
@@ -11,6 +11,8 @@ module.exports = ({ cwd, redisUrl, uploadDir, verbose, env }) => {
const workerId = parseInt(process.env.JEST_WORKER_ID || "1") const workerId = parseInt(process.env.JEST_WORKER_ID || "1")
const redisUrlWithDatabase = `${redisUrl}/${workerId - 1}` const redisUrlWithDatabase = `${redisUrl}/${workerId - 1}`
verbose = verbose ?? false
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const medusaProcess = spawn("node", [path.resolve(serverPath)], { const medusaProcess = spawn("node", [path.resolve(serverPath)], {
cwd, cwd,
@@ -21,6 +23,7 @@ module.exports = ({ cwd, redisUrl, uploadDir, verbose, env }) => {
COOKIE_SECRET: "test", COOKIE_SECRET: "test",
REDIS_URL: redisUrl ? redisUrlWithDatabase : undefined, // If provided, will use a real instance, otherwise a fake instance REDIS_URL: redisUrl ? redisUrlWithDatabase : undefined, // If provided, will use a real instance, otherwise a fake instance
UPLOAD_DIR: uploadDir, // If provided, will be used for the fake local file service UPLOAD_DIR: uploadDir, // If provided, will be used for the fake local file service
CACHE_TTL: 0, // By default the cache service is disabled and 0 means that none of the cache key/value will be stored.
...env, ...env,
}, },
stdio: verbose stdio: verbose
+1 -1
View File
@@ -63,7 +63,7 @@
"jest": "jest", "jest": "jest",
"test": "turbo run test", "test": "turbo run test",
"test:integration": "NODE_ENV=test jest --runInBand --bail --config=integration-tests/jest.config.js", "test:integration": "NODE_ENV=test jest --runInBand --bail --config=integration-tests/jest.config.js",
"test:integration:api": "NODE_ENV=test jest --detectOpenHandles --forceExit --runInBand --bail --config=integration-tests/jest.config.js --projects=integration-tests/api", "test:integration:api": "NODE_ENV=test jest --detectOpenHandles --forceExit --runInBand --bail --config=integration-tests/jest.config.js --projects=integration-tests/api -- integration-tests/api/__tests__/store/cart/cart.js",
"test:integration:plugins": "NODE_ENV=test jest --runInBand --bail --config=integration-tests/jest.config.js --projects=integration-tests/plugins", "test:integration:plugins": "NODE_ENV=test jest --runInBand --bail --config=integration-tests/jest.config.js --projects=integration-tests/plugins",
"test:fixtures": "NODE_ENV=test jest --config=docs-util/jest.config.js --runInBand --bail", "test:fixtures": "NODE_ENV=test jest --config=docs-util/jest.config.js --runInBand --bail",
"openapi:generate": "node ./scripts/build-openapi.js", "openapi:generate": "node ./scripts/build-openapi.js",
@@ -98,19 +98,24 @@ describe("POST /store/carts", () => {
}) })
it("calls line item generate", () => { it("calls line item generate", () => {
expect(CartServiceMock.addOrUpdateLineItems).toHaveBeenCalledTimes(1)
expect(LineItemServiceMock.generate).toHaveBeenCalledWith( expect(LineItemServiceMock.generate).toHaveBeenCalledWith(
IdMap.getId("testVariant"), [
IdMap.getId("testRegion"), {
3, variantId: IdMap.getId("testVariant"),
{ customer_id: undefined } quantity: 3,
},
{
variantId: IdMap.getId("testVariant1"),
quantity: 1,
},
],
{
region_id: IdMap.getId("testRegion"),
customer_id: undefined,
}
) )
expect(LineItemServiceMock.generate).toHaveBeenCalledWith(
IdMap.getId("testVariant1"),
IdMap.getId("testRegion"),
1,
{ customer_id: undefined }
)
expect(CartServiceMock.addLineItem).toHaveBeenCalledTimes(2)
}) })
it("returns cart", () => { it("returns cart", () => {
@@ -17,7 +17,7 @@ import {
RegionService, RegionService,
} from "../../../../services" } from "../../../../services"
import { defaultStoreCartFields, defaultStoreCartRelations } from "." import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { Cart } from "../../../../models" import { Cart, LineItem } from "../../../../models"
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators" import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
import { FlagRouter } from "../../../../utils/flag-router" import { FlagRouter } from "../../../../utils/flag-router"
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
@@ -179,24 +179,30 @@ export default async (req, res) => {
let cart: Cart let cart: Cart
await entityManager.transaction(async (manager) => { await entityManager.transaction(async (manager) => {
cart = await cartService.withTransaction(manager).create(toCreate) const cartServiceTx = cartService.withTransaction(manager)
const lineItemServiceTx = lineItemService.withTransaction(manager)
if (validated.items) { cart = await cartServiceTx.create(toCreate)
await Promise.all(
validated.items.map(async (i) => { if (validated.items?.length) {
const lineItem = await lineItemService const generateInputData = validated.items.map((item) => {
.withTransaction(manager) return {
.generate(i.variant_id, regionId, i.quantity, { variantId: item.variant_id,
customer_id: req.user?.customer_id, quantity: item.quantity,
}) }
return await cartService })
.withTransaction(manager) const generatedLineItems: LineItem[] = await lineItemServiceTx.generate(
.addLineItem(cart.id, lineItem, { generateInputData,
validateSalesChannels: {
featureFlagRouter.isFeatureEnabled("sales_channels"), region_id: regionId,
}) customer_id: req.user?.customer_id,
}) }
) )
await cartServiceTx.addOrUpdateLineItems(cart.id, generatedLineItems, {
validateSalesChannels:
featureFlagRouter.isFeatureEnabled("sales_channels"),
})
} }
}) })
+1
View File
@@ -10,3 +10,4 @@ export * from "./models/base-entity"
export * from "./models/soft-deletable-entity" export * from "./models/soft-deletable-entity"
export * from "./search-service" export * from "./search-service"
export * from "./payment-service" export * from "./payment-service"
export * from "./services"
@@ -0,0 +1,7 @@
export interface ICacheService {
get<T>(key: string): Promise<T | null>
set(key: string, data: unknown, ttl?: number): Promise<void>
invalidate(key: string): Promise<void>
}
@@ -0,0 +1 @@
export * from "./cache"
@@ -172,11 +172,19 @@ export class MoneyAmountRepository extends Repository<MoneyAmount> {
} }
if (region_id || currency_code) { if (region_id || currency_code) {
qb.andWhere( qb.andWhere(
new Brackets((qb) => new Brackets((qb) => {
qb if (region_id && !currency_code) {
.where({ region_id: region_id }) qb.where({ region_id: region_id })
.orWhere({ currency_code: currency_code }) }
) if (!region_id && currency_code) {
qb.where({ currency_code: currency_code })
}
if (currency_code && region_id) {
qb.where({ region_id: region_id }).orWhere({
currency_code: currency_code,
})
}
})
) )
} else if (!customer_id && !include_discount_prices) { } else if (!customer_id && !include_discount_prices) {
qb.andWhere("price_list.id IS null") qb.andWhere("price_list.id IS null")
@@ -0,0 +1,11 @@
export const cacheServiceMock = {
set: jest.fn().mockImplementation(async () => void 0),
get: jest.fn().mockImplementation(async () => null),
invalidate: jest.fn().mockImplementation(async () => void 0),
}
const mock = jest.fn().mockImplementation(() => {
return cacheServiceMock
})
export default mock
@@ -319,6 +319,9 @@ export const CartServiceMock = {
addLineItem: jest.fn().mockImplementation((cartId, lineItem) => { addLineItem: jest.fn().mockImplementation((cartId, lineItem) => {
return Promise.resolve() return Promise.resolve()
}), }),
addOrUpdateLineItems: jest.fn().mockImplementation((cartId, lineItem) => {
return Promise.resolve()
}),
setPaymentMethod: jest.fn().mockImplementation((cartId, method) => { setPaymentMethod: jest.fn().mockImplementation((cartId, method) => {
if (method.provider_id === "default_provider") { if (method.provider_id === "default_provider") {
return Promise.resolve(carts.cartWithPaySessions) return Promise.resolve(carts.cartWithPaySessions)
@@ -1,6 +1,8 @@
import { IdMap, MockManager, MockRepository } from "medusa-test-utils" import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import { FlagRouter } from "../../utils/flag-router" import { FlagRouter } from "../../utils/flag-router"
import DiscountService from "../discount" import DiscountService from "../discount"
import { TotalsServiceMock } from "../__mocks__/totals"
import { newTotalsServiceMock } from "../__mocks__/new-totals"
const featureFlagRouter = new FlagRouter({}) const featureFlagRouter = new FlagRouter({})
@@ -601,15 +603,28 @@ describe("DiscountService", () => {
}) })
const totalsService = { const totalsService = {
getSubtotal: () => { ...TotalsServiceMock,
getSubtotal: async () => {
return 1100 return 1100
}, },
} }
const newTotalsService = {
...newTotalsServiceMock,
getLineItemTotals: async () => {
return [
{
subtotal: 1100,
},
]
},
}
const discountService = new DiscountService({ const discountService = new DiscountService({
manager: MockManager, manager: MockManager,
discountRepository, discountRepository,
totalsService, totalsService,
newTotalsService,
featureFlagRouter, featureFlagRouter,
}) })
@@ -631,21 +646,31 @@ describe("DiscountService", () => {
}) })
it("correctly calculates fixed + total discount", async () => { it("correctly calculates fixed + total discount", async () => {
let item = {
unit_price: 400,
quantity: 2,
allow_discounts: true,
}
const adjustment1 = await discountService.calculateDiscountForLineItem( const adjustment1 = await discountService.calculateDiscountForLineItem(
"disc_fixed_total", "disc_fixed_total",
item,
{ {
unit_price: 400, items: [item],
quantity: 2,
allow_discounts: true,
} }
) )
item = {
unit_price: 300,
quantity: 1,
allow_discounts: true,
}
const adjustment2 = await discountService.calculateDiscountForLineItem( const adjustment2 = await discountService.calculateDiscountForLineItem(
"disc_fixed_total", "disc_fixed_total",
item,
{ {
unit_price: 300, items: [item],
quantity: 1,
allow_discounts: true,
} }
) )
@@ -32,9 +32,7 @@ import { RegionServiceMock } from "../__mocks__/region"
} }
const productVariantService = { const productVariantService = {
withTransaction: function () { ...ProductVariantServiceMock,
return this
},
retrieve: (query) => { retrieve: (query) => {
if (query === IdMap.getId("test-giftcard")) { if (query === IdMap.getId("test-giftcard")) {
return { return {
@@ -58,15 +56,25 @@ import { RegionServiceMock } from "../__mocks__/region"
} }
}, },
getRegionPrice: () => 100, getRegionPrice: () => 100,
list: jest.fn().mockImplementation(async (selector) => {
return (selector.id || []).map((id) => ({
id,
title: "Test variant",
product: {
title: "Test product",
thumbnail: "",
discountable: false,
is_giftcard: true,
},
}))
}),
} }
const pricingService = { const pricingService = {
withTransaction: function () { ...PricingServiceMock,
return this getProductVariantsPricing: () => {
},
getProductVariantPricingById: () => {
return { return {
calculated_price: 100, [IdMap.getId("test-giftcard")]: { calculated_price: 100 },
} }
}, },
getProductVariantPricing: () => { getProductVariantPricing: () => {
@@ -106,15 +114,17 @@ import { RegionServiceMock } from "../__mocks__/region"
}) })
expect(lineItemRepository.create).toHaveBeenCalledTimes(1) expect(lineItemRepository.create).toHaveBeenCalledTimes(1)
expect(lineItemRepository.create).toHaveBeenCalledWith({ expect(lineItemRepository.create).toHaveBeenCalledWith([
variant_id: IdMap.getId("test-variant"), {
cart_id: IdMap.getId("test-cart"), variant_id: IdMap.getId("test-variant"),
title: "Test product", cart_id: IdMap.getId("test-cart"),
description: "Test variant", title: "Test product",
thumbnail: "", description: "Test variant",
unit_price: 100, thumbnail: "",
quantity: 1, unit_price: 100,
}) quantity: 1,
},
])
}) })
it("successfully create a line item with price and quantity", async () => { it("successfully create a line item with price and quantity", async () => {
@@ -126,12 +136,14 @@ import { RegionServiceMock } from "../__mocks__/region"
}) })
expect(lineItemRepository.create).toHaveBeenCalledTimes(1) expect(lineItemRepository.create).toHaveBeenCalledTimes(1)
expect(lineItemRepository.create).toHaveBeenCalledWith({ expect(lineItemRepository.create).toHaveBeenCalledWith([
variant_id: IdMap.getId("test-variant"), {
cart_id: IdMap.getId("test-cart"), variant_id: IdMap.getId("test-variant"),
unit_price: 50, cart_id: IdMap.getId("test-cart"),
quantity: 2, unit_price: 50,
}) quantity: 2,
},
])
}) })
it("successfully create a line item giftcard", async () => { it("successfully create a line item giftcard", async () => {
@@ -147,8 +159,7 @@ import { RegionServiceMock } from "../__mocks__/region"
}) })
expect(lineItemRepository.create).toHaveBeenCalledTimes(2) expect(lineItemRepository.create).toHaveBeenCalledTimes(2)
expect(lineItemRepository.create).toHaveBeenNthCalledWith( expect(lineItemRepository.create).toHaveBeenNthCalledWith(2, [
2,
expect.objectContaining({ expect.objectContaining({
allow_discounts: false, allow_discounts: false,
variant_id: IdMap.getId("test-giftcard"), variant_id: IdMap.getId("test-giftcard"),
@@ -161,8 +172,8 @@ import { RegionServiceMock } from "../__mocks__/region"
is_giftcard: true, is_giftcard: true,
should_merge: true, should_merge: true,
metadata: {}, metadata: {},
}) }),
) ])
}) })
}) })
@@ -190,6 +201,7 @@ import { RegionServiceMock } from "../__mocks__/region"
const lineItemService = new LineItemService({ const lineItemService = new LineItemService({
manager: MockManager, manager: MockManager,
lineItemRepository, lineItemRepository,
productVariantService: ProductVariantServiceMock,
}) })
beforeEach(async () => { beforeEach(async () => {
@@ -329,9 +341,7 @@ describe("LineItemService", () => {
} }
const productVariantService = { const productVariantService = {
withTransaction: function () { ...ProductVariantServiceMock,
return this
},
retrieve: (query) => { retrieve: (query) => {
if (query === IdMap.getId("test-giftcard")) { if (query === IdMap.getId("test-giftcard")) {
return { return {
@@ -355,16 +365,26 @@ describe("LineItemService", () => {
} }
}, },
getRegionPrice: () => 100, getRegionPrice: () => 100,
list: jest.fn().mockImplementation(async (selector) => {
return (selector.id || []).map((id) => ({
id,
title: "Test variant",
product: {
title: "Test product",
thumbnail: "",
},
}))
}),
} }
const pricingService = { const pricingService = {
withTransaction: function () { ...PricingServiceMock,
return this getProductVariantsPricing: () => {
},
getProductVariantPricingById: () => {
return { return {
calculated_price: 100, [IdMap.getId("test-variant")]: {
calculated_price_includes_tax: true, calculated_price: 100,
calculated_price_includes_tax: true,
},
} }
}, },
getProductVariantPricing: () => { getProductVariantPricing: () => {
@@ -423,6 +443,41 @@ describe("LineItemService", () => {
}), }),
}) })
}) })
it("successfully create a line item with tax inclusive set to true by passing an object", async () => {
await lineItemService.generate(
{
variantId: IdMap.getId("test-variant"),
quantity: 1,
},
{
region_id: IdMap.getId("test-region"),
}
)
expect(lineItemRepository.create).toHaveBeenCalledTimes(1)
expect(lineItemRepository.create).toHaveBeenCalledWith({
unit_price: 100,
title: "Test product",
description: "Test variant",
thumbnail: "",
variant_id: IdMap.getId("test-variant"),
quantity: 1,
allow_discounts: undefined,
is_giftcard: undefined,
metadata: {},
should_merge: true,
includes_tax: true,
variant: expect.objectContaining({
id: expect.any(String),
product: expect.objectContaining({
thumbnail: "",
title: "Test product",
}),
title: "Test variant",
}),
})
})
}) })
describe("generate", () => { describe("generate", () => {
const lineItemRepository = MockRepository({ const lineItemRepository = MockRepository({
@@ -448,9 +503,7 @@ describe("LineItemService", () => {
} }
const productVariantService = { const productVariantService = {
withTransaction: function () { ...ProductVariantServiceMock,
return this
},
retrieve: (query) => { retrieve: (query) => {
if (query === IdMap.getId("test-giftcard")) { if (query === IdMap.getId("test-giftcard")) {
return { return {
@@ -464,26 +517,30 @@ describe("LineItemService", () => {
}, },
} }
} }
return {
id: IdMap.getId("test-variant"),
title: "Test variant",
product: {
title: "Test product",
thumbnail: "",
},
}
}, },
getRegionPrice: () => 100, getRegionPrice: () => 100,
list: jest.fn().mockImplementation(async (selector) => {
return (selector.id || []).map((id) => {
return {
id,
title: "Test variant",
product: {
title: "Test product",
thumbnail: "",
},
}
})
}),
} }
const pricingService = { const pricingService = {
withTransaction: function () { ...PricingServiceMock,
return this getProductVariantsPricing: () => {
},
getProductVariantPricingById: () => {
return { return {
calculated_price: 100, [IdMap.getId("test-variant")]: {
calculated_price_includes_tax: false, calculated_price: 100,
calculated_price_includes_tax: false,
},
} }
}, },
getProductVariantPricing: () => { getProductVariantPricing: () => {
@@ -542,6 +599,41 @@ describe("LineItemService", () => {
}), }),
}) })
}) })
it("successfully create a line item with tax inclusive set to false by passing an object", async () => {
await lineItemService.generate(
{
variantId: IdMap.getId("test-variant"),
quantity: 1,
},
{
region_id: IdMap.getId("test-region"),
}
)
expect(lineItemRepository.create).toHaveBeenCalledTimes(1)
expect(lineItemRepository.create).toHaveBeenCalledWith({
unit_price: 100,
title: "Test product",
description: "Test variant",
thumbnail: "",
variant_id: IdMap.getId("test-variant"),
quantity: 1,
allow_discounts: undefined,
is_giftcard: undefined,
metadata: {},
should_merge: true,
includes_tax: false,
variant: expect.objectContaining({
id: expect.any(String),
product: expect.objectContaining({
thumbnail: "",
title: "Test product",
}),
title: "Test variant",
}),
})
})
}) })
describe("clone", () => { describe("clone", () => {
+67
View File
@@ -0,0 +1,67 @@
import { Redis } from "ioredis"
import { ICacheService } from "../interfaces"
const DEFAULT_CACHE_TIME = 30 // 30 seconds
const EXPIRY_MODE = "EX" // "EX" stands for an expiry time in second
export default class CacheService implements ICacheService {
protected readonly redis_: Redis
constructor({ redisClient }) {
this.redis_ = redisClient
}
/**
* Set a key/value pair to the cache.
* It is also possible to manage the ttl through environment variable using CACHE_TTL. If the ttl is 0 it will
* act like the value should not be cached at all.
* @param key
* @param data
* @param ttl
*/
async set(
key: string,
data: Record<string, unknown>,
ttl: number = DEFAULT_CACHE_TIME
): Promise<void> {
ttl = Number(process.env.CACHE_TTL ?? ttl)
if (ttl === 0) {
// No need to call redis set without expiry time
return
}
await this.redis_.set(key, JSON.stringify(data), EXPIRY_MODE, ttl)
}
/**
* Retrieve a cached value belonging to the given key.
* @param cacheKey
*/
async get<T>(cacheKey: string): Promise<T | null> {
try {
const cached = await this.redis_.get(cacheKey)
if (cached) {
return JSON.parse(cached)
}
} catch (err) {
await this.redis_.del(cacheKey)
}
return null
}
/**
* Invalidate cache for a specific key. a key can be either a specific key or more global such as "ps:*".
* @param key
*/
async invalidate(key: string): Promise<void> {
await this.redis_.del()
const keys = await this.redis_.keys(key)
const pipeline = this.redis_.pipeline()
keys.forEach(function (key) {
pipeline.del(key)
})
await pipeline.exec()
}
}
+176 -11
View File
@@ -26,6 +26,7 @@ import {
FilterableCartProps, FilterableCartProps,
isCart, isCart,
LineItemUpdate, LineItemUpdate,
LineItemValidateData,
} from "../types/cart" } from "../types/cart"
import { import {
AddressPayload, AddressPayload,
@@ -331,7 +332,7 @@ class CartService extends TransactionBaseService {
rawCart.email = customer.email rawCart.email = customer.email
} }
if (!data.region_id) { if (!data.region_id && !data.region) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.INVALID_DATA, MedusaError.Types.INVALID_DATA,
`A region_id must be provided when creating a cart` `A region_id must be provided when creating a cart`
@@ -339,11 +340,13 @@ class CartService extends TransactionBaseService {
} }
rawCart.region_id = data.region_id rawCart.region_id = data.region_id
const region = await this.regionService_ const region = data.region
.withTransaction(transactionManager) ? data.region
.retrieve(data.region_id, { : await this.regionService_
relations: ["countries"], .withTransaction(transactionManager)
}) .retrieve(data.region_id!, {
relations: ["countries"],
})
const regCountries = region.countries.map(({ iso_2 }) => iso_2) const regCountries = region.countries.map(({ iso_2 }) => iso_2)
if (!data.shipping_address && !data.shipping_address_id) { if (!data.shipping_address && !data.shipping_address_id) {
@@ -555,15 +558,17 @@ class CartService extends TransactionBaseService {
*/ */
protected async validateLineItem( protected async validateLineItem(
{ sales_channel_id }: { sales_channel_id: string | null }, { sales_channel_id }: { sales_channel_id: string | null },
lineItem: LineItem lineItem: LineItemValidateData
): Promise<boolean> { ): Promise<boolean> {
if (!sales_channel_id) { if (!sales_channel_id) {
return true return true
} }
const lineItemVariant = await this.productVariantService_ const lineItemVariant = lineItem.variant?.product_id
.withTransaction(this.manager_) ? lineItem.variant
.retrieve(lineItem.variant_id) : await this.productVariantService_
.withTransaction(this.manager_)
.retrieve(lineItem.variant_id, { select: ["id", "product_id"] })
return !!( return !!(
await this.productService_ await this.productService_
@@ -583,6 +588,7 @@ class CartService extends TransactionBaseService {
* validateSalesChannels - should check if product belongs to the same sales chanel as cart * validateSalesChannels - should check if product belongs to the same sales chanel as cart
* (if cart has associated sales channel) * (if cart has associated sales channel)
* @return the result of the update operation * @return the result of the update operation
* @deprecated Use {@link addOrUpdateLineItems} instead.
*/ */
async addLineItem( async addLineItem(
cartId: string, cartId: string,
@@ -659,7 +665,166 @@ class CartService extends TransactionBaseService {
{ cart_id: cartId, has_shipping: true }, { cart_id: cartId, has_shipping: true },
{ has_shipping: false } { has_shipping: false }
) )
.catch(() => void 0) .catch((err: Error | MedusaError) => {
// We only want to catch the errors related to not found items since we don't care if there is not item to update
if ("type" in err && err.type === MedusaError.Types.NOT_FOUND) {
return
}
throw err
})
cart = await this.retrieve(cart.id, {
relations: ["items", "discounts", "discounts.rule", "region"],
})
await this.refreshAdjustments_(cart)
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, { id: cart.id })
}
)
}
/**
* Adds or update one or multiple line items to the cart. It also update all existing items in the cart
* to have has_shipping to false. Finally, the adjustments will be updated.
* @param cartId - the id of the cart that we will add to
* @param lineItems - the line items to add.
* @param config
* validateSalesChannels - should check if product belongs to the same sales chanel as cart
* (if cart has associated sales channel)
* @return the result of the update operation
*/
async addOrUpdateLineItems(
cartId: string,
lineItems: LineItem | LineItem[],
config = { validateSalesChannels: true }
): Promise<void> {
const items: LineItem[] = Array.isArray(lineItems) ? lineItems : [lineItems]
const select: (keyof Cart)[] = ["id"]
if (this.featureFlagRouter_.isFeatureEnabled("sales_channels")) {
select.push("sales_channel_id")
}
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
let cart = await this.retrieve(cartId, { select })
if (this.featureFlagRouter_.isFeatureEnabled("sales_channels")) {
if (config.validateSalesChannels) {
const areValid = await Promise.all(
items.map(async (item) => {
return await this.validateLineItem(cart, item)
})
)
const invalidProducts = areValid
.map((valid, index) => {
return !valid ? { title: items[index].title } : undefined
})
.filter((v): v is { title: string } => !!v)
if (invalidProducts.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`The products [${invalidProducts
.map((item) => item.title)
.join(
" - "
)}] must belongs to the sales channel on which the cart has been created.`
)
}
}
}
const lineItemServiceTx =
this.lineItemService_.withTransaction(transactionManager)
const inventoryServiceTx =
this.inventoryService_.withTransaction(transactionManager)
const existingItems = await lineItemServiceTx.list(
{
cart_id: cart.id,
variant_id: In([items.map((item) => item.variant_id)]),
should_merge: true,
},
{ select: ["id", "metadata", "quantity"] }
)
const existingItemsVariantMap = new Map()
existingItems.forEach((item) => {
existingItemsVariantMap.set(item.variant_id, item)
})
const lineItemsToCreate: LineItem[] = []
const lineItemsToUpdate: { [id: string]: LineItem }[] = []
for (const item of items) {
let currentItem: LineItem | undefined
const existingItem = existingItemsVariantMap.get(item.variant_id)
if (item.should_merge) {
if (existingItem && isEqual(existingItem.metadata, item.metadata)) {
currentItem = existingItem
}
}
// If content matches one of the line items currently in the cart we can
// simply update the quantity of the existing line item
item.quantity = currentItem
? (currentItem.quantity += item.quantity)
: item.quantity
await inventoryServiceTx.confirmInventory(
item.variant_id,
item.quantity
)
if (currentItem) {
lineItemsToUpdate[currentItem.id] = {
quantity: item.quantity,
has_shipping: false,
}
} else {
// Since the variant is eager loaded, we are removing it before the line item is being created.
delete (item as Partial<LineItem>).variant
item.has_shipping = false
item.cart_id = cart.id
lineItemsToCreate.push(item)
}
}
const itemKeysToUpdate = Object.keys(lineItemsToUpdate)
// Update all items that needs to be updated
if (itemKeysToUpdate.length) {
await Promise.all(
itemKeysToUpdate.map(async (id) => {
return await lineItemServiceTx.update(id, lineItemsToUpdate[id])
})
)
}
// Create all items that needs to be created
await lineItemServiceTx.create(lineItemsToCreate)
await lineItemServiceTx
.update(
{
cart_id: cartId,
has_shipping: true,
},
{ has_shipping: false }
)
.catch((err: Error | MedusaError) => {
// We only want to catch the errors related to not found items since we don't care if there is not item to update
if ("type" in err && err.type === MedusaError.Types.NOT_FOUND) {
return
}
throw err
})
cart = await this.retrieve(cart.id, { cart = await this.retrieve(cart.id, {
relations: ["items", "discounts", "discounts.rule", "region"], relations: ["items", "discounts", "discounts.rule", "region"],
+22 -9
View File
@@ -41,6 +41,7 @@ import { isFuture, isPast } from "../utils/date-helpers"
import { FlagRouter } from "../utils/flag-router" import { FlagRouter } from "../utils/flag-router"
import CustomerService from "./customer" import CustomerService from "./customer"
import DiscountConditionService from "./discount-condition" import DiscountConditionService from "./discount-condition"
import { CalculationContextData } from "../types/totals"
/** /**
* Provides layer to manipulate discounts. * Provides layer to manipulate discounts.
@@ -573,7 +574,7 @@ class DiscountService extends TransactionBaseService {
async calculateDiscountForLineItem( async calculateDiscountForLineItem(
discountId: string, discountId: string,
lineItem: LineItem, lineItem: LineItem,
cart: Cart calculationContextData: CalculationContextData
): Promise<number> { ): Promise<number> {
return await this.atomicPhase_(async (transactionManager) => { return await this.atomicPhase_(async (transactionManager) => {
let adjustment = 0 let adjustment = 0
@@ -586,6 +587,12 @@ class DiscountService extends TransactionBaseService {
const { type, value, allocation } = discount.rule const { type, value, allocation } = discount.rule
const calculationContext = await this.totalsService_
.withTransaction(transactionManager)
.getCalculationContext(calculationContextData, {
exclude_shipping: true,
})
let fullItemPrice = lineItem.unit_price * lineItem.quantity let fullItemPrice = lineItem.unit_price * lineItem.quantity
if ( if (
this.featureFlagRouter_.isFeatureEnabled( this.featureFlagRouter_.isFeatureEnabled(
@@ -593,11 +600,6 @@ class DiscountService extends TransactionBaseService {
) && ) &&
lineItem.includes_tax lineItem.includes_tax
) { ) {
const calculationContext = await this.totalsService_
.withTransaction(transactionManager)
.getCalculationContext(cart, {
exclude_shipping: true,
})
const lineItemTotals = await this.newTotalsService_ const lineItemTotals = await this.newTotalsService_
.withTransaction(transactionManager) .withTransaction(transactionManager)
.getLineItemTotals([lineItem], { .getLineItemTotals([lineItem], {
@@ -616,15 +618,26 @@ class DiscountService extends TransactionBaseService {
// when a fixed discount should be applied to the total, // when a fixed discount should be applied to the total,
// we create line adjustments for each item with an amount // we create line adjustments for each item with an amount
// relative to the subtotal // relative to the subtotal
const subtotal = await this.totalsService_.getSubtotal(cart, { const discountedItems = calculationContextData.items.filter(
excludeNonDiscounts: true, (item) => item.allow_discounts
}) )
const totals = await this.newTotalsService_.getLineItemTotals(
discountedItems,
{
calculationContext,
}
)
const subtotal = Object.values(totals).reduce((subtotal, total) => {
subtotal += total.subtotal
return subtotal
}, 0)
const nominator = Math.min(value, subtotal) const nominator = Math.min(value, subtotal)
const totalItemPercentage = fullItemPrice / subtotal const totalItemPercentage = fullItemPrice / subtotal
adjustment = Math.round(nominator * totalItemPercentage) adjustment = Math.round(nominator * totalItemPercentage)
} else { } else {
adjustment = value * lineItem.quantity adjustment = value * lineItem.quantity
} }
// if the amount of the discount exceeds the total price of the item, // if the amount of the discount exceeds the total price of the item,
// we return the total item price, else the fixed amount // we return the total item price, else the fixed amount
return adjustment >= fullItemPrice ? fullItemPrice : adjustment return adjustment >= fullItemPrice ? fullItemPrice : adjustment
+16 -16
View File
@@ -55,28 +55,28 @@ export default class EventBusService {
config: ConfigModule, config: ConfigModule,
singleton = true singleton = true
) { ) {
const opts = {
createClient: (type: string): Redis.Redis => {
switch (type) {
case "client":
return redisClient
case "subscriber":
return redisSubscriber
default:
if (config.projectConfig.redis_url) {
return new Redis(config.projectConfig.redis_url)
}
return redisClient
}
},
}
this.config_ = config this.config_ = config
this.manager_ = manager this.manager_ = manager
this.logger_ = logger this.logger_ = logger
this.stagedJobRepository_ = stagedJobRepository this.stagedJobRepository_ = stagedJobRepository
if (singleton) { if (singleton) {
const opts = {
createClient: (type: string): Redis.Redis => {
switch (type) {
case "client":
return redisClient
case "subscriber":
return redisSubscriber
default:
if (config.projectConfig.redis_url) {
return new Redis(config.projectConfig.redis_url)
}
return redisClient
}
},
}
this.observers_ = new Map() this.observers_ = new Map()
this.queue_ = new Bull(`${this.constructor.name}:queue`, opts) this.queue_ = new Bull(`${this.constructor.name}:queue`, opts)
this.cronHandlers_ = new Map() this.cronHandlers_ = new Map()
+2 -1
View File
@@ -2,6 +2,7 @@ export { default as AnalyticsConfigService } from "./analytics-config"
export { default as AuthService } from "./auth" export { default as AuthService } from "./auth"
export { default as BatchJobService } from "./batch-job" export { default as BatchJobService } from "./batch-job"
export { default as CartService } from "./cart" export { default as CartService } from "./cart"
export { default as CacheService } from "./cache"
export { default as ClaimService } from "./claim" export { default as ClaimService } from "./claim"
export { default as ClaimItemService } from "./claim-item" export { default as ClaimItemService } from "./claim-item"
export { default as CurrencyService } from "./currency" export { default as CurrencyService } from "./currency"
@@ -35,7 +36,7 @@ export { default as ProductService } from "./product"
export { default as ProductCollectionService } from "./product-collection" export { default as ProductCollectionService } from "./product-collection"
export { default as ProductTypeService } from "./product-type" export { default as ProductTypeService } from "./product-type"
export { default as ProductVariantService } from "./product-variant" export { default as ProductVariantService } from "./product-variant"
import { default as PublishableApiKey } from "./publishable-api-key"
export { default as RegionService } from "./region" export { default as RegionService } from "./region"
export { default as ReturnService } from "./return" export { default as ReturnService } from "./return"
export { default as ReturnReasonService } from "./return-reason" export { default as ReturnReasonService } from "./return-reason"
+12 -4
View File
@@ -3,6 +3,7 @@ import { TransactionBaseService } from "../interfaces"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import ProductVariantService from "./product-variant" import ProductVariantService from "./product-variant"
import { ProductVariant } from "../models" import { ProductVariant } from "../models"
import { isDefined } from "../utils"
type InventoryServiceProps = { type InventoryServiceProps = {
manager: EntityManager manager: EntityManager
@@ -62,22 +63,29 @@ class InventoryService extends TransactionBaseService {
* @return true if the inventory covers the quantity * @return true if the inventory covers the quantity
*/ */
async confirmInventory( async confirmInventory(
variantId: string | undefined | null, variantId: string | null | undefined,
quantity: number quantity: number
): Promise<boolean> { ): Promise<boolean> {
// if variantId is undefined then confirm inventory as it // if variantId is undefined then confirm inventory as it
// is a custom item that is not managed // is a custom item that is not managed
if (typeof variantId === "undefined" || variantId === null) { if (!isDefined(variantId) || variantId === null) {
return true return true
} }
const variant = await this.productVariantService_ const variant = await this.productVariantService_
.withTransaction(this.manager_) .withTransaction(this.manager_)
.retrieve(variantId) .retrieve(variantId, {
select: [
"id",
"inventory_quantity",
"allow_backorder",
"manage_inventory",
],
})
const { inventory_quantity, allow_backorder, manage_inventory } = variant const { inventory_quantity, allow_backorder, manage_inventory } = variant
const isCovered = const isCovered =
!manage_inventory || allow_backorder || inventory_quantity >= quantity !manage_inventory || allow_backorder || inventory_quantity >= quantity
if (!isCovered) { if (!isCovered) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.NOT_ALLOWED, MedusaError.Types.NOT_ALLOWED,
@@ -1,19 +1,14 @@
import { MedusaError } from "medusa-core-utils" import { MedusaError } from "medusa-core-utils"
import { EntityManager, In } from "typeorm" import { EntityManager, In } from "typeorm"
import { import { Cart, DiscountRuleType, LineItem, LineItemAdjustment } from "../models"
Cart,
DiscountRuleType,
LineItem,
LineItemAdjustment,
ProductVariant,
} from "../models"
import { LineItemAdjustmentRepository } from "../repositories/line-item-adjustment" import { LineItemAdjustmentRepository } from "../repositories/line-item-adjustment"
import { FindConfig } from "../types/common" import { FindConfig } from "../types/common"
import { FilterableLineItemAdjustmentProps } from "../types/line-item-adjustment" import { FilterableLineItemAdjustmentProps } from "../types/line-item-adjustment"
import DiscountService from "./discount" import DiscountService from "./discount"
import { TransactionBaseService } from "../interfaces" import { TransactionBaseService } from "../interfaces"
import { buildQuery, setMetadata } from "../utils" import { buildQuery, setMetadata } from "../utils"
import { CalculationContextData } from "../types/totals"
type LineItemAdjustmentServiceProps = { type LineItemAdjustmentServiceProps = {
manager: EntityManager manager: EntityManager
@@ -22,7 +17,7 @@ type LineItemAdjustmentServiceProps = {
} }
type AdjustmentContext = { type AdjustmentContext = {
variant: ProductVariant variant: { product_id: string }
} }
type GeneratedAdjustment = { type GeneratedAdjustment = {
@@ -174,13 +169,13 @@ class LineItemAdjustmentService extends TransactionBaseService {
/** /**
* Creates adjustment for a line item * Creates adjustment for a line item
* @param cart - the cart object holding discounts * @param calculationContextData - the calculationContextData object holding discounts
* @param generatedLineItem - the line item for which a line item adjustment might be created * @param generatedLineItem - the line item for which a line item adjustment might be created
* @param context - the line item for which a line item adjustment might be created * @param context - the line item for which a line item adjustment might be created
* @return a line item adjustment or undefined if no adjustment was created * @return a line item adjustment or undefined if no adjustment was created
*/ */
async generateAdjustments( async generateAdjustments(
cart: Cart, calculationContextData: CalculationContextData,
generatedLineItem: LineItem, generatedLineItem: LineItem,
context: AdjustmentContext context: AdjustmentContext
): Promise<GeneratedAdjustment[]> { ): Promise<GeneratedAdjustment[]> {
@@ -196,12 +191,12 @@ class LineItemAdjustmentService extends TransactionBaseService {
if ( if (
!lineItem.allow_discounts || !lineItem.allow_discounts ||
lineItem.is_return || lineItem.is_return ||
!cart?.discounts?.length !calculationContextData?.discounts?.length
) { ) {
return [] return []
} }
const [discount] = cart.discounts.filter( const [discount] = calculationContextData.discounts.filter(
(d) => d.rule.type !== DiscountRuleType.FREE_SHIPPING (d) => d.rule.type !== DiscountRuleType.FREE_SHIPPING
) )
@@ -226,7 +221,7 @@ class LineItemAdjustmentService extends TransactionBaseService {
const amount = await this.discountService.calculateDiscountForLineItem( const amount = await this.discountService.calculateDiscountForLineItem(
discount.id, discount.id,
lineItem, lineItem,
cart calculationContextData
) )
// if discounted amount is 0, then do nothing // if discounted amount is 0, then do nothing
@@ -234,15 +229,13 @@ class LineItemAdjustmentService extends TransactionBaseService {
return [] return []
} }
const adjustments = [ return [
{ {
amount, amount,
discount_id: discount.id, discount_id: discount.id,
description: "discount", description: "discount",
}, },
] ]
return adjustments
}) })
} }
+220 -98
View File
@@ -5,7 +5,12 @@ import { DeepPartial } from "typeorm/common/DeepPartial"
import { CartRepository } from "../repositories/cart" import { CartRepository } from "../repositories/cart"
import { LineItemRepository } from "../repositories/line-item" import { LineItemRepository } from "../repositories/line-item"
import { LineItemTaxLineRepository } from "../repositories/line-item-tax-line" import { LineItemTaxLineRepository } from "../repositories/line-item-tax-line"
import { Cart, LineItem, LineItemAdjustment, LineItemTaxLine } from "../models" import {
LineItem,
LineItemAdjustment,
LineItemTaxLine,
ProductVariant,
} from "../models"
import { FindConfig, Selector } from "../types/common" import { FindConfig, Selector } from "../types/common"
import { FlagRouter } from "../utils/flag-router" import { FlagRouter } from "../utils/flag-router"
import LineItemAdjustmentService from "./line-item-adjustment" import LineItemAdjustmentService from "./line-item-adjustment"
@@ -18,8 +23,10 @@ import {
RegionService, RegionService,
TaxProviderService, TaxProviderService,
} from "./index" } from "./index"
import { buildQuery, setMetadata } from "../utils" import { buildQuery, isString, setMetadata } from "../utils"
import { TransactionBaseService } from "../interfaces" import { TransactionBaseService } from "../interfaces"
import { GenerateInputData, GenerateLineItemContext } from "../types/line-item"
import { ProductVariantPricing } from "../types/pricing"
type InjectedDependencies = { type InjectedDependencies = {
manager: EntityManager manager: EntityManager
@@ -178,115 +185,205 @@ class LineItemService extends TransactionBaseService {
) )
} }
async generate( /**
variantId: string, * Generate a single or multiple line item without persisting the data into the db
regionId: string, * @param variantIdOrData
quantity: number, * @param regionIdOrContext
context: { * @param quantity
unit_price?: number * @param context
includes_tax?: boolean */
metadata?: Record<string, unknown> async generate<
customer_id?: string T = string | GenerateInputData | GenerateInputData[],
order_edit_id?: string TResult = T extends string
cart?: Cart ? LineItem
} = {} : T extends LineItem
): Promise<LineItem> { ? LineItem
: LineItem[]
>(
variantIdOrData: string | T,
regionIdOrContext: T extends string ? string : GenerateLineItemContext,
quantity?: number,
context: GenerateLineItemContext = {}
): Promise<TResult> {
return await this.atomicPhase_( return await this.atomicPhase_(
async (transactionManager: EntityManager) => { async (transactionManager: EntityManager) => {
const [variant, region] = await Promise.all([ this.validateGenerateArguments(
this.productVariantService_ variantIdOrData,
.withTransaction(transactionManager) regionIdOrContext,
.retrieve(variantId, { quantity
relations: ["product"],
}),
this.regionService_
.withTransaction(transactionManager)
.retrieve(regionId),
])
let unit_price = Number(context.unit_price) < 0 ? 0 : context.unit_price
let unitPriceIncludesTax = false
let shouldMerge = false
if (context.unit_price === undefined || context.unit_price === null) {
shouldMerge = true
const variantPricing = await this.pricingService_
.withTransaction(transactionManager)
.getProductVariantPricingById(variant.id, {
region_id: region.id,
quantity: quantity,
customer_id: context?.customer_id,
include_discount_prices: true,
})
unitPriceIncludesTax = !!variantPricing.calculated_price_includes_tax
unit_price = variantPricing.calculated_price ?? undefined
}
const rawLineItem: Partial<LineItem> = {
unit_price: unit_price,
title: variant.product.title,
description: variant.title,
thumbnail: variant.product.thumbnail,
variant_id: variant.id,
quantity: quantity || 1,
allow_discounts: variant.product.discountable,
is_giftcard: variant.product.is_giftcard,
metadata: context?.metadata || {},
should_merge: shouldMerge,
}
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
)
) {
rawLineItem.includes_tax = unitPriceIncludesTax
}
if (
this.featureFlagRouter_.isFeatureEnabled(OrderEditingFeatureFlag.key)
) {
rawLineItem.order_edit_id = context.order_edit_id || null
}
const lineItemRepo = transactionManager.getCustomRepository(
this.lineItemRepository_
) )
const lineItem = lineItemRepo.create({
...rawLineItem,
variant,
})
if (context.cart) { const data = isString(variantIdOrData)
const adjustments = await this.lineItemAdjustmentService_ ? {
.withTransaction(transactionManager) variantId: variantIdOrData,
.generateAdjustments(context.cart, lineItem, { variant }) quantity: quantity as number,
lineItem.adjustments = adjustments as unknown as LineItemAdjustment[] }
: variantIdOrData
const resolvedContext = isString(variantIdOrData)
? context
: (regionIdOrContext as GenerateLineItemContext)
const regionId = (
isString(variantIdOrData)
? regionIdOrContext
: resolvedContext.region_id
) as string
const resolvedData = (
Array.isArray(data) ? data : [data]
) as GenerateInputData[]
const variants = await this.productVariantService_.list(
{
id: resolvedData.map((d) => d.variantId),
},
{
relations: ["product"],
}
)
const variantsMap = new Map<string, ProductVariant>()
const variantIdsToCalculatePricingFor: string[] = []
for (const variant of variants) {
variantsMap.set(variant.id, variant)
if (resolvedContext.unit_price == null) {
variantIdsToCalculatePricingFor.push(variant.id)
}
} }
return lineItem const variantsPricing = await this.pricingService_
.withTransaction(transactionManager)
.getProductVariantsPricing(variantIdsToCalculatePricingFor, {
region_id: regionId,
quantity: quantity,
customer_id: context?.customer_id,
include_discount_prices: true,
})
const generatedItems: LineItem[] = []
for (const variantData of resolvedData) {
const variant = variantsMap.get(
variantData.variantId
) as ProductVariant
const variantPricing = variantsPricing[variantData.variantId]
const lineItem = await this.generateLineItem(
variant,
variantData.quantity,
{
...resolvedContext,
variantPricing,
}
)
if (resolvedContext.cart) {
const adjustments = await this.lineItemAdjustmentService_
.withTransaction(transactionManager)
.generateAdjustments(resolvedContext.cart, lineItem, { variant })
lineItem.adjustments =
adjustments as unknown as LineItemAdjustment[]
}
generatedItems.push(lineItem)
}
return (Array.isArray(data)
? generatedItems
: generatedItems[0]) as unknown as TResult
} }
) )
} }
protected async generateLineItem(
variant: {
id: string
title: string
product_id: string
product: {
title: string
thumbnail: string | null
discountable: boolean
is_giftcard: boolean
}
},
quantity: number,
context: GenerateLineItemContext & {
variantPricing: ProductVariantPricing
}
): Promise<LineItem> {
const transactionManager = this.transactionManager_ ?? this.manager_
let unit_price = Number(context.unit_price) < 0 ? 0 : context.unit_price
let unitPriceIncludesTax = false
let shouldMerge = false
if (context.unit_price == null) {
shouldMerge = true
unitPriceIncludesTax =
!!context.variantPricing?.calculated_price_includes_tax
unit_price = context.variantPricing?.calculated_price ?? undefined
}
const rawLineItem: Partial<LineItem> = {
unit_price: unit_price,
title: variant.product.title,
description: variant.title,
thumbnail: variant.product.thumbnail,
variant_id: variant.id,
quantity: quantity || 1,
allow_discounts: variant.product.discountable,
is_giftcard: variant.product.is_giftcard,
metadata: context?.metadata || {},
should_merge: shouldMerge,
}
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
)
) {
rawLineItem.includes_tax = unitPriceIncludesTax
}
if (this.featureFlagRouter_.isFeatureEnabled(OrderEditingFeatureFlag.key)) {
rawLineItem.order_edit_id = context.order_edit_id || null
}
const lineItemRepo = transactionManager.getCustomRepository(
this.lineItemRepository_
)
const lineItem = lineItemRepo.create(rawLineItem)
lineItem.variant = variant as ProductVariant
return lineItem
}
/** /**
* Create a line item * Create a line item
* @param data - the line item object to create * @param data - the line item object to create
* @return the created line item * @return the created line item
*/ */
async create(data: Partial<LineItem>): Promise<LineItem> { async create<
T = LineItem | LineItem[],
TResult = T extends LineItem ? LineItem : LineItem[]
>(data: T): Promise<TResult> {
return await this.atomicPhase_( return await this.atomicPhase_(
async (transactionManager: EntityManager) => { async (transactionManager: EntityManager) => {
const lineItemRepository = transactionManager.getCustomRepository( const lineItemRepository = transactionManager.getCustomRepository(
this.lineItemRepository_ this.lineItemRepository_
) )
const item = lineItemRepository.create(data) const data_ = Array.isArray(data) ? data : [data]
return await lineItemRepository.save(item)
const items = lineItemRepository.create(data_)
const lineItems = await lineItemRepository.save(items)
return (Array.isArray(data)
? lineItems
: lineItems[0]) as unknown as TResult
} }
) )
} }
@@ -326,14 +423,8 @@ class LineItemService extends TransactionBaseService {
} }
lineItems = lineItems.map((item) => { lineItems = lineItems.map((item) => {
const lineItemMetadata = metadata item.metadata = metadata ? setMetadata(item, metadata) : item.metadata
? setMetadata(item, metadata) return Object.assign(item, rest)
: item.metadata
return Object.assign(item, {
...rest,
metadata: lineItemMetadata,
})
}) })
return await lineItemRepository.save(lineItems) return await lineItemRepository.save(lineItems)
@@ -464,6 +555,37 @@ class LineItemService extends TransactionBaseService {
return await lineItemRepository.save(clonedLineItemEntities) return await lineItemRepository.save(clonedLineItemEntities)
}) })
} }
protected validateGenerateArguments<
T = string | GenerateInputData | GenerateInputData[],
TResult = T extends string
? LineItem
: T extends LineItem
? LineItem
: LineItem[]
>(
variantIdOrData: string | T,
regionIdOrContext: T extends string ? string : GenerateLineItemContext,
quantity?: number
): void | never {
if (isString(variantIdOrData)) {
if (!quantity || !regionIdOrContext || !isString(regionIdOrContext)) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"The generate method has been called with a variant id but one of the argument quantity or regionId is missing. Please, provide the variantId, quantity and regionId."
)
}
} else {
const resolvedContext = regionIdOrContext as GenerateLineItemContext
if (!resolvedContext.region_id) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"The generate method has been called with the data but the context is missing either region_id or region. Please provide at least one of region or region_id."
)
}
}
}
} }
export default LineItemService export default LineItemService
+68 -2
View File
@@ -7,7 +7,7 @@ import {
PriceSelectionContext, PriceSelectionContext,
} from "../interfaces/price-selection-strategy" } from "../interfaces/price-selection-strategy"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing" import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { Product, ProductVariant, ShippingOption } from "../models" import { Product, ProductVariant, Region, ShippingOption } from "../models"
import { import {
PricedProduct, PricedProduct,
PricedShippingOption, PricedShippingOption,
@@ -73,8 +73,9 @@ class PricingService extends TransactionBaseService {
let taxRate: number | null = null let taxRate: number | null = null
let currencyCode = context.currency_code let currencyCode = context.currency_code
let region: Region
if (context.region_id) { if (context.region_id) {
const region = await this.regionService region = await this.regionService
.withTransaction(this.manager_) .withTransaction(this.manager_)
.retrieve(context.region_id, { .retrieve(context.region_id, {
select: ["id", "currency_code", "automatic_taxes", "tax_rate"], select: ["id", "currency_code", "automatic_taxes", "tax_rate"],
@@ -247,6 +248,7 @@ class PricingService extends TransactionBaseService {
* @param variantId - the id of the variant to get prices for * @param variantId - the id of the variant to get prices for
* @param context - the price selection context to use * @param context - the price selection context to use
* @return The product variant prices * @return The product variant prices
* @deprecated Use {@link getProductVariantsPricing} instead.
*/ */
async getProductVariantPricingById( async getProductVariantPricingById(
variantId: string, variantId: string,
@@ -267,6 +269,7 @@ class PricingService extends TransactionBaseService {
const { product_id } = await this.productVariantService const { product_id } = await this.productVariantService
.withTransaction(this.manager_) .withTransaction(this.manager_)
.retrieve(variantId, { select: ["id", "product_id"] }) .retrieve(variantId, { select: ["id", "product_id"] })
productRates = await this.taxProviderService productRates = await this.taxProviderService
.withTransaction(this.manager_) .withTransaction(this.manager_)
.getRegionRatesForProduct(product_id, { .getRegionRatesForProduct(product_id, {
@@ -282,6 +285,69 @@ class PricingService extends TransactionBaseService {
) )
} }
/**
* Gets the prices for a collection of variants.
* @param variantIds - the id of the variants to get the prices for
* @param context - the price selection context to use
* @return The product variant prices
*/
async getProductVariantsPricing<
T = string | string[],
TOutput = T extends string
? ProductVariantPricing
: { [variant_id: string]: ProductVariantPricing }
>(
variantIds: T,
context: PriceSelectionContext | PricingContext
): Promise<TOutput> {
let pricingContext: PricingContext
if ("automatic_taxes" in context) {
pricingContext = context
} else {
pricingContext = await this.collectPricingContext(context)
}
const ids = (
Array.isArray(variantIds) ? variantIds : [variantIds]
) as string[]
const variants = await this.productVariantService
.withTransaction(this.manager_)
.list({ id: ids }, { select: ["id", "product_id"] })
const variantsMap = new Map(
variants.map((variant) => {
return [variant.id, variant]
})
)
const pricingResult: { [variant_id: string]: ProductVariantPricing } = {}
for (const variantId of ids) {
const { id, product_id } = variantsMap.get(variantId)!
let productRates: TaxServiceRate[] = []
if (pricingContext.price_selection.region_id) {
productRates = await this.taxProviderService
.withTransaction(this.manager_)
.getRegionRatesForProduct(product_id, {
id: pricingContext.price_selection.region_id,
tax_rate: pricingContext.tax_rate,
})
}
pricingResult[id] = await this.getProductVariantPricing_(
id,
productRates,
pricingContext
)
}
return (!Array.isArray(variantIds)
? Object.values(pricingResult)[0]
: pricingResult) as unknown as TOutput
}
private async getProductPricing_( private async getProductPricing_(
productId: string, productId: string,
variants: ProductVariant[], variants: ProductVariant[],
+23 -11
View File
@@ -6,6 +6,7 @@ import {
} from "../interfaces" } from "../interfaces"
import { import {
Cart, Cart,
ClaimOrder,
Discount, Discount,
DiscountRuleType, DiscountRuleType,
LineItem, LineItem,
@@ -13,10 +14,12 @@ import {
Order, Order,
ShippingMethod, ShippingMethod,
ShippingMethodTaxLine, ShippingMethodTaxLine,
Swap,
} from "../models" } from "../models"
import { isCart } from "../types/cart" import { isCart } from "../types/cart"
import { isOrder } from "../types/orders" import { isOrder } from "../types/orders"
import { import {
CalculationContextData,
LineAllocationsMap, LineAllocationsMap,
LineDiscount, LineDiscount,
LineDiscountAmount, LineDiscountAmount,
@@ -429,7 +432,12 @@ class TotalsService extends TransactionBaseService {
* @return the allocation map for the line items in the cart or order. * @return the allocation map for the line items in the cart or order.
*/ */
async getAllocationMap( async getAllocationMap(
orderOrCart: Cart | Order, orderOrCart: {
discounts?: Discount[]
items: LineItem[]
swaps?: Swap[]
claims?: ClaimOrder[]
},
options: AllocationMapOptions = {} options: AllocationMapOptions = {}
): Promise<LineAllocationsMap> { ): Promise<LineAllocationsMap> {
const allocationMap: LineAllocationsMap = {} const allocationMap: LineAllocationsMap = {}
@@ -700,19 +708,23 @@ class TotalsService extends TransactionBaseService {
* order * order
*/ */
getLineDiscounts( getLineDiscounts(
cartOrOrder: Cart | Order, cartOrOrder: {
items: LineItem[]
swaps?: Swap[]
claims?: ClaimOrder[]
},
discount: Discount discount: Discount
): LineDiscountAmount[] { ): LineDiscountAmount[] {
let merged: LineItem[] = [...(cartOrOrder.items ?? [])] let merged: LineItem[] = [...(cartOrOrder.items ?? [])]
// merge items from order with items from order swaps // merge items from order with items from order swaps
if ("swaps" in cartOrOrder && cartOrOrder.swaps.length) { if ("swaps" in cartOrOrder && cartOrOrder.swaps?.length) {
for (const s of cartOrOrder.swaps) { for (const s of cartOrOrder.swaps) {
merged = [...merged, ...s.additional_items] merged = [...merged, ...s.additional_items]
} }
} }
if ("claims" in cartOrOrder && cartOrOrder.claims.length) { if ("claims" in cartOrOrder && cartOrOrder.claims?.length) {
for (const c of cartOrOrder.claims) { for (const c of cartOrOrder.claims) {
merged = [...merged, ...c.additional_items] merged = [...merged, ...c.additional_items]
} }
@@ -1051,15 +1063,15 @@ class TotalsService extends TransactionBaseService {
/** /**
* Prepares the calculation context for a tax total calculation. * Prepares the calculation context for a tax total calculation.
* @param cartOrOrder - the cart or order to get the calculation context for * @param calculationContextData - the calculationContextData to get the calculation context for
* @param options - options to gather context by * @param options - options to gather context by
* @return the tax calculation context * @return the tax calculation context
*/ */
async getCalculationContext( async getCalculationContext(
cartOrOrder: Cart | Order, calculationContextData: CalculationContextData,
options: CalculationContextOptions = {} options: CalculationContextOptions = {}
): Promise<TaxCalculationContext> { ): Promise<TaxCalculationContext> {
const allocationMap = await this.getAllocationMap(cartOrOrder, { const allocationMap = await this.getAllocationMap(calculationContextData, {
exclude_gift_cards: options.exclude_gift_cards, exclude_gift_cards: options.exclude_gift_cards,
exclude_discounts: options.exclude_discounts, exclude_discounts: options.exclude_discounts,
}) })
@@ -1067,14 +1079,14 @@ class TotalsService extends TransactionBaseService {
let shippingMethods: ShippingMethod[] = [] let shippingMethods: ShippingMethod[] = []
// Default to include shipping methods // Default to include shipping methods
if (!options.exclude_shipping) { if (!options.exclude_shipping) {
shippingMethods = cartOrOrder.shipping_methods || [] shippingMethods = calculationContextData.shipping_methods || []
} }
return { return {
shipping_address: cartOrOrder.shipping_address, shipping_address: calculationContextData.shipping_address,
shipping_methods: shippingMethods, shipping_methods: shippingMethods,
customer: cartOrOrder.customer, customer: calculationContextData.customer,
region: cartOrOrder.region, region: calculationContextData.region,
is_return: options.is_return ?? false, is_return: options.is_return ?? false,
allocation_map: allocationMap, allocation_map: allocationMap,
} }
@@ -1,6 +1,7 @@
import TaxInclusivePricingFeatureFlag from "../../loaders/feature-flags/tax-inclusive-pricing" import TaxInclusivePricingFeatureFlag from "../../loaders/feature-flags/tax-inclusive-pricing"
import { FlagRouter } from "../../utils/flag-router" import { FlagRouter } from "../../utils/flag-router"
import PriceSelectionStrategy from "../price-selection" import PriceSelectionStrategy from "../price-selection"
import { cacheServiceMock } from "../../services/__mocks__/cache"
const executeTest = const executeTest =
(flagValue) => (flagValue) =>
@@ -226,6 +227,7 @@ const executeTest =
manager: mockEntityManager, manager: mockEntityManager,
moneyAmountRepository: mockMoneyAmountRepository, moneyAmountRepository: mockMoneyAmountRepository,
featureFlagRouter, featureFlagRouter,
cacheService: cacheServiceMock,
}) })
try { try {
@@ -211,13 +211,19 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
return await this.atomicPhase_( return await this.atomicPhase_(
async (transactionManager) => { async (transactionManager) => {
let batchJob = (await this.batchJobService_ const productServiceTx =
.withTransaction(transactionManager) this.productService_.withTransaction(transactionManager)
.retrieve(batchJobId)) as ProductExportBatchJob const batchJobServiceTx =
this.batchJobService_.withTransaction(transactionManager)
const fileServiceTx =
this.fileService_.withTransaction(transactionManager)
const { writeStream, fileKey, promise } = await this.fileService_ let batchJob = (await batchJobServiceTx.retrieve(
.withTransaction(transactionManager) batchJobId
.getUploadStreamDescriptor({ )) as ProductExportBatchJob
const { writeStream, fileKey, promise } =
await fileServiceTx.getUploadStreamDescriptor({
name: `exports/products/product-export-${Date.now()}`, name: `exports/products/product-export-${Date.now()}`,
ext: "csv", ext: "csv",
}) })
@@ -226,14 +232,12 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
writeStream.write(header) writeStream.write(header)
approximateFileSize += Buffer.from(header).byteLength approximateFileSize += Buffer.from(header).byteLength
await this.batchJobService_ await batchJobServiceTx.update(batchJobId, {
.withTransaction(transactionManager) result: {
.update(batchJobId, { file_key: fileKey,
result: { file_size: approximateFileSize,
file_key: fileKey, },
file_size: approximateFileSize, })
},
})
advancementCount = advancementCount =
batchJob.result?.advancement_count ?? advancementCount batchJob.result?.advancement_count ?? advancementCount
@@ -241,26 +245,25 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
limit = batchJob.context?.list_config?.take ?? limit limit = batchJob.context?.list_config?.take ?? limit
const { list_config = {}, filterable_fields = {} } = batchJob.context const { list_config = {}, filterable_fields = {} } = batchJob.context
const [productList, count] = await this.productService_ const [productList, count] = await productServiceTx.listAndCount(
.withTransaction(transactionManager) filterable_fields,
.listAndCount(filterable_fields, { {
...list_config, ...list_config,
skip: offset, skip: offset,
take: Math.min(batchJob.context.batch_size ?? Infinity, limit), take: Math.min(batchJob.context.batch_size ?? Infinity, limit),
} as FindProductConfig) } as FindProductConfig
)
productCount = batchJob.context?.batch_size ?? count productCount = batchJob.context?.batch_size ?? count
let products: Product[] = productList let products: Product[] = productList
while (offset < productCount) { while (offset < productCount) {
if (!products?.length) { if (!products?.length) {
products = await this.productService_ products = await productServiceTx.list(filterable_fields, {
.withTransaction(transactionManager) ...list_config,
.list(filterable_fields, { skip: offset,
...list_config, take: Math.min(productCount - offset, limit),
skip: offset, } as FindProductConfig)
take: Math.min(productCount - offset, limit),
} as FindProductConfig)
} }
products.forEach((product: Product) => { products.forEach((product: Product) => {
@@ -275,16 +278,14 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy {
offset += products.length offset += products.length
products = [] products = []
batchJob = (await this.batchJobService_ batchJob = (await batchJobServiceTx.update(batchJobId, {
.withTransaction(transactionManager) result: {
.update(batchJobId, { file_size: approximateFileSize,
result: { count: productCount,
file_size: approximateFileSize, advancement_count: advancementCount,
count: productCount, progress: advancementCount / productCount,
advancement_count: advancementCount, },
progress: advancementCount / productCount, })) as ProductExportBatchJob
},
})) as ProductExportBatchJob
if (batchJob.status === BatchJobStatus.CANCELED) { if (batchJob.status === BatchJobStatus.CANCELED) {
writeStream.end() writeStream.end()
@@ -1,27 +1,36 @@
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { import {
AbstractPriceSelectionStrategy, AbstractPriceSelectionStrategy,
ICacheService,
IPriceSelectionStrategy, IPriceSelectionStrategy,
PriceSelectionContext, PriceSelectionContext,
PriceSelectionResult, PriceSelectionResult,
PriceType, PriceType,
} from "../interfaces/price-selection-strategy" } from "../interfaces"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing" import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { MoneyAmountRepository } from "../repositories/money-amount" import { MoneyAmountRepository } from "../repositories/money-amount"
import { TaxServiceRate } from "../types/tax-service" import { TaxServiceRate } from "../types/tax-service"
import { FlagRouter } from "../utils/flag-router" import { FlagRouter } from "../utils/flag-router"
import { isDefined } from "../utils/is-defined" import { isDefined } from "../utils"
class PriceSelectionStrategy extends AbstractPriceSelectionStrategy { class PriceSelectionStrategy extends AbstractPriceSelectionStrategy {
private moneyAmountRepository_: typeof MoneyAmountRepository protected manager_: EntityManager
private featureFlagRouter_: FlagRouter
private manager_: EntityManager
constructor({ manager, featureFlagRouter, moneyAmountRepository }) { protected readonly featureFlagRouter_: FlagRouter
protected moneyAmountRepository_: typeof MoneyAmountRepository
protected cacheService_: ICacheService
constructor({
manager,
featureFlagRouter,
moneyAmountRepository,
cacheService,
}) {
super() super()
this.manager_ = manager this.manager_ = manager
this.moneyAmountRepository_ = moneyAmountRepository this.moneyAmountRepository_ = moneyAmountRepository
this.featureFlagRouter_ = featureFlagRouter this.featureFlagRouter_ = featureFlagRouter
this.cacheService_ = cacheService
} }
withTransaction(manager: EntityManager): IPriceSelectionStrategy { withTransaction(manager: EntityManager): IPriceSelectionStrategy {
@@ -33,6 +42,7 @@ class PriceSelectionStrategy extends AbstractPriceSelectionStrategy {
manager: manager, manager: manager,
moneyAmountRepository: this.moneyAmountRepository_, moneyAmountRepository: this.moneyAmountRepository_,
featureFlagRouter: this.featureFlagRouter_, featureFlagRouter: this.featureFlagRouter_,
cacheService: this.cacheService_,
}) })
} }
@@ -40,14 +50,30 @@ class PriceSelectionStrategy extends AbstractPriceSelectionStrategy {
variant_id: string, variant_id: string,
context: PriceSelectionContext context: PriceSelectionContext
): Promise<PriceSelectionResult> { ): Promise<PriceSelectionResult> {
// TODO: Refactor using the cache decorators when it will be finished
const cacheKey = this.getCacheKey(variant_id, context)
const cached = await this.cacheService_
.get<PriceSelectionResult>(cacheKey)
.catch(() => void 0)
if (cached) {
return cached
}
let result
if ( if (
this.featureFlagRouter_.isFeatureEnabled( this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key TaxInclusivePricingFeatureFlag.key
) )
) { ) {
return this.calculateVariantPrice_new(variant_id, context) result = await this.calculateVariantPrice_new(variant_id, context)
} else {
result = await this.calculateVariantPrice_old(variant_id, context)
} }
return this.calculateVariantPrice_old(variant_id, context)
await this.cacheService_.set(cacheKey, result)
return result
} }
private async calculateVariantPrice_new( private async calculateVariantPrice_new(
@@ -213,6 +239,21 @@ class PriceSelectionStrategy extends AbstractPriceSelectionStrategy {
return result return result
} }
private getCacheKey(
variantId: string,
context: PriceSelectionContext
): string {
const taxRate =
context.tax_rates?.reduce(
(accRate: number, nextTaxRate: TaxServiceRate) => {
return accRate + (nextTaxRate.rate || 0) / 100
},
0
) || 0
return `ps:${variantId}:${context.region_id}:${context.currency_code}:${context.customer_id}:${context.quantity}:${context.include_discount_prices}:${taxRate}`
}
} }
const isValidAmount = ( const isValidAmount = (
@@ -0,0 +1,33 @@
import {
CacheService,
EventBusService,
ProductVariantService,
} from "../services"
type ProductVariantUpdatedEventData = {
id: string
product_id: string
fields: string[]
}
class PricingSubscriber {
protected readonly eventBus_: EventBusService
protected readonly cacheService_: CacheService
constructor({ eventBusService, cacheService }) {
this.eventBus_ = eventBusService
this.cacheService_ = cacheService
this.eventBus_.subscribe(
ProductVariantService.Events.UPDATED,
async (data) => {
const { id, fields } = data as ProductVariantUpdatedEventData
if (fields.includes("prices")) {
await this.cacheService_.invalidate(`ps:${id}*`)
}
}
)
}
}
export default PricingSubscriber
+8 -5
View File
@@ -1,11 +1,8 @@
import { ValidateNested } from "class-validator" import { ValidateNested } from "class-validator"
import { IsType } from "../utils/validators/is-type" import { IsType } from "../utils/validators/is-type"
import { Cart, CartType } from "../models/cart" import { Cart, CartType } from "../models/cart"
import { import { AddressPayload, DateComparisonOperator, StringComparisonOperator } from "./common"
AddressPayload, import { Region } from "../models"
DateComparisonOperator,
StringComparisonOperator,
} from "./common"
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isCart(object: any): object is Cart { export function isCart(object: any): object is Cart {
@@ -34,6 +31,11 @@ export type LineItemUpdate = {
variant_id?: string variant_id?: string
} }
export type LineItemValidateData = {
variant?: { product_id: string };
variant_id: string
}
class GiftCard { class GiftCard {
code: string code: string
} }
@@ -44,6 +46,7 @@ class Discount {
export type CartCreateProps = { export type CartCreateProps = {
region_id?: string region_id?: string
region?: Region
email?: string email?: string
billing_address_id?: string billing_address_id?: string
billing_address?: Partial<AddressPayload> billing_address?: Partial<AddressPayload>
+16
View File
@@ -0,0 +1,16 @@
import { CalculationContextData } from "./totals"
export type GenerateInputData = {
variantId: string
quantity: number
}
export type GenerateLineItemContext = {
region_id?: string
unit_price?: number
includes_tax?: boolean
metadata?: Record<string, unknown>
customer_id?: string
order_edit_id?: string
cart?: CalculationContextData
}
+21 -1
View File
@@ -1,4 +1,24 @@
import { LineItem } from "../models" import {
Address,
ClaimOrder,
Customer,
Discount,
LineItem,
Region,
ShippingMethod,
Swap,
} from "../models"
export type CalculationContextData = {
discounts: Discount[]
items: LineItem[]
customer: Customer
region: Region
shipping_address: Address | null
swaps?: Swap[]
claims?: ClaimOrder[]
shipping_methods?: ShippingMethod[]
}
/** The amount of a gift card allocated to a line item */ /** The amount of a gift card allocated to a line item */
export type GiftCardAllocation = { export type GiftCardAllocation = {