feat: Implement PriceList and extend MoneyAmount (#1152)

* init

* added buld id validation to repo

* admin done

* updated price reqs

* intial implementation of PriceList

* integration tests for price lists

* updated admin/product integration tests

* update updateVariantPrices method

* remove comment from error handler

* add integration test for batch deleting prices associated with price list

* make update to prices through variant service limited to default prices

* update store/products.js snapshot

* add api unit tests and update product integration tests to validate that prices from Price List are ignored

* fix product test

* requested changes

* cascade

* ensure delete variant cascades to MoneyAmount

* addresses PR feedback

* removed unused endpoint

* update mock

* fix failing store integration tests

* remove medusajs ressource

* re add env.template

* Update integration-tests/api/__tests__/admin/price-list.js

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

* Update integration-tests/api/__tests__/admin/price-list.js

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

* fix: update snapshots

Co-authored-by: Sebastian Rindom <skrindom@gmail.com>
Co-authored-by: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com>
This commit is contained in:
Kasper Fabricius Kristensen
2022-03-18 15:18:50 +01:00
committed by GitHub
co-authored by Sebastian Rindom Philip Korsholm
parent 23f8399c16
commit 5300926db8
52 changed files with 4234 additions and 505 deletions
@@ -0,0 +1,293 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`/admin/price-lists GET /admin/price-lists returns a price list by :id 1`] = `
Object {
"created_at": Any<String>,
"customer_groups": Array [],
"deleted_at": null,
"description": "Winter sale for VIP customers. 25% off selected items.",
"ends_at": "2022-07-31T00:00:00.000Z",
"id": Any<String>,
"name": "VIP winter sale",
"prices": Array [
Object {
"amount": 100,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": 100,
"min_quantity": 1,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
Object {
"amount": 80,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": 500,
"min_quantity": 101,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
Object {
"amount": 50,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": 1000,
"min_quantity": 501,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
],
"starts_at": "2022-07-01T00:00:00.000Z",
"status": "active",
"type": "sale",
"updated_at": Any<String>,
}
`;
exports[`/admin/price-lists POST /admin/price-lists/:id updates a price list 1`] = `
Object {
"created_at": Any<String>,
"customer_groups": Array [
Object {
"created_at": Any<String>,
"deleted_at": null,
"id": "customer-group-1",
"metadata": null,
"name": "vip-customers",
"updated_at": Any<String>,
},
],
"deleted_at": null,
"description": "Winter sale for our most loyal customers",
"ends_at": "2022-12-31T00:00:00.000Z",
"id": "pl_no_customer_groups",
"name": "Loyalty Reward - Winter Sale",
"prices": Array [
Object {
"amount": 100,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": 100,
"min_quantity": 1,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
Object {
"amount": 80,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": 500,
"min_quantity": 101,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
Object {
"amount": 50,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": 1000,
"min_quantity": 501,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
Object {
"amount": 85,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": null,
"min_quantity": null,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant_1",
},
Object {
"amount": 10,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": null,
"min_quantity": null,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
],
"starts_at": "2022-09-01T00:00:00.000Z",
"status": "draft",
"type": "sale",
"updated_at": Any<String>,
}
`;
exports[`/admin/price-lists POST /admin/price-lists/:id updates the amount and currency of a price in the price list 1`] = `
Object {
"amount": 250,
"created_at": Any<String>,
"currency_code": "eur",
"deleted_at": null,
"id": "ma_test_1",
"max_quantity": 100,
"min_quantity": 1,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
}
`;
exports[`/admin/price-lists POST /admin/price-lists/:id/prices/batch Adds a batch of new prices to a price list overriding existing prices 1`] = `
Array [
Object {
"amount": 45,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": 2000,
"min_quantity": 1001,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
Object {
"amount": 35,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": 3000,
"min_quantity": 2001,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
Object {
"amount": 25,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": 4000,
"min_quantity": 3001,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
]
`;
exports[`/admin/price-lists POST /admin/price-lists/:id/prices/batch Adds a batch of new prices to a price list without overriding existing prices 1`] = `
Array [
Object {
"amount": 100,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": 100,
"min_quantity": 1,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
Object {
"amount": 80,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": 500,
"min_quantity": 101,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
Object {
"amount": 50,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": 1000,
"min_quantity": 501,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
Object {
"amount": 45,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": 2000,
"min_quantity": 1001,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
Object {
"amount": 35,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": 3000,
"min_quantity": 2001,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
Object {
"amount": 25,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": 4000,
"min_quantity": 3001,
"price_list_id": "pl_no_customer_groups",
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
]
`;
@@ -110,8 +110,10 @@ Array [
"currency_code": "usd",
"deleted_at": null,
"id": StringMatching /\\^test-price\\*/,
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"sale_amount": null,
"updated_at": Any<String>,
"variant_id": StringMatching /\\^test-variant\\*/,
},
@@ -159,8 +161,10 @@ Array [
"currency_code": "usd",
"deleted_at": null,
"id": StringMatching /\\^test-price\\*/,
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"sale_amount": null,
"updated_at": Any<String>,
"variant_id": StringMatching /\\^test-variant\\*/,
},
@@ -208,8 +212,10 @@ Array [
"currency_code": "usd",
"deleted_at": null,
"id": StringMatching /\\^test-price\\*/,
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"sale_amount": null,
"updated_at": Any<String>,
"variant_id": StringMatching /\\^test-variant\\*/,
},
@@ -315,8 +321,10 @@ Array [
"currency_code": "usd",
"deleted_at": null,
"id": StringMatching /\\^test-price\\*/,
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"sale_amount": null,
"updated_at": Any<String>,
"variant_id": StringMatching /\\^test-variant\\*/,
},
@@ -364,8 +372,10 @@ Array [
"currency_code": "usd",
"deleted_at": null,
"id": StringMatching /\\^test-price\\*/,
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": "test-region",
"sale_amount": null,
"updated_at": Any<String>,
"variant_id": StringMatching /\\^test-variant\\*/,
},
@@ -561,8 +571,10 @@ Array [
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"sale_amount": null,
"updated_at": Any<String>,
"variant_id": StringMatching /\\^variant_\\*/,
},
@@ -581,3 +593,318 @@ Array [
},
]
`;
exports[`/admin/products POST /admin/products creates a product 1`] = `
Object {
"collection": Object {
"created_at": Any<String>,
"deleted_at": null,
"handle": "test-collection",
"id": "test-collection",
"metadata": null,
"title": "Test collection",
"updated_at": Any<String>,
},
"collection_id": "test-collection",
"created_at": Any<String>,
"deleted_at": null,
"description": "test-product-description",
"discountable": true,
"external_id": null,
"handle": "test",
"height": null,
"hs_code": null,
"id": StringMatching /\\^prod_\\*/,
"images": Array [
Object {
"created_at": Any<String>,
"deleted_at": null,
"id": Any<String>,
"metadata": null,
"updated_at": Any<String>,
"url": "test-image.png",
},
Object {
"created_at": Any<String>,
"deleted_at": null,
"id": Any<String>,
"metadata": null,
"updated_at": Any<String>,
"url": "test-image-2.png",
},
],
"is_giftcard": false,
"length": null,
"material": null,
"metadata": null,
"mid_code": null,
"options": Array [
Object {
"created_at": Any<String>,
"deleted_at": null,
"id": StringMatching /\\^opt_\\*/,
"metadata": null,
"product_id": StringMatching /\\^prod_\\*/,
"title": "size",
"updated_at": Any<String>,
},
Object {
"created_at": Any<String>,
"deleted_at": null,
"id": StringMatching /\\^opt_\\*/,
"metadata": null,
"product_id": StringMatching /\\^prod_\\*/,
"title": "color",
"updated_at": Any<String>,
},
],
"origin_country": null,
"profile_id": StringMatching /\\^sp_\\*/,
"status": "draft",
"subtitle": null,
"tags": Array [
Object {
"created_at": Any<String>,
"deleted_at": null,
"id": Any<String>,
"metadata": null,
"updated_at": Any<String>,
"value": "123",
},
Object {
"created_at": Any<String>,
"deleted_at": null,
"id": Any<String>,
"metadata": null,
"updated_at": Any<String>,
"value": "456",
},
],
"thumbnail": "test-image.png",
"title": "Test",
"type": Object {
"created_at": Any<String>,
"deleted_at": null,
"id": "test-type",
"metadata": null,
"updated_at": Any<String>,
"value": "test-type",
},
"type_id": "test-type",
"updated_at": Any<String>,
"variants": Array [
Object {
"allow_backorder": false,
"barcode": null,
"created_at": Any<String>,
"deleted_at": null,
"ean": null,
"height": null,
"hs_code": null,
"id": StringMatching /\\^variant_\\*/,
"inventory_quantity": 10,
"length": null,
"manage_inventory": true,
"material": null,
"metadata": null,
"mid_code": null,
"options": Array [
Object {
"created_at": Any<String>,
"deleted_at": null,
"id": StringMatching /\\^optval_\\*/,
"metadata": null,
"option_id": StringMatching /\\^opt_\\*/,
"updated_at": Any<String>,
"value": "large",
"variant_id": StringMatching /\\^variant_\\*/,
},
Object {
"created_at": Any<String>,
"deleted_at": null,
"id": StringMatching /\\^optval_\\*/,
"metadata": null,
"option_id": StringMatching /\\^opt_\\*/,
"updated_at": Any<String>,
"value": "green",
"variant_id": StringMatching /\\^variant_\\*/,
},
],
"origin_country": null,
"prices": Array [
Object {
"amount": 100,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": StringMatching /\\^ma_\\*/,
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"updated_at": Any<String>,
"variant_id": StringMatching /\\^variant_\\*/,
},
Object {
"amount": 45,
"created_at": Any<String>,
"currency_code": "eur",
"deleted_at": null,
"id": StringMatching /\\^ma_\\*/,
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"updated_at": Any<String>,
"variant_id": StringMatching /\\^variant_\\*/,
},
Object {
"amount": 30,
"created_at": Any<String>,
"currency_code": "dkk",
"deleted_at": null,
"id": StringMatching /\\^ma_\\*/,
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"updated_at": Any<String>,
"variant_id": StringMatching /\\^variant_\\*/,
},
],
"product_id": StringMatching /\\^prod_\\*/,
"sku": null,
"title": "Test variant",
"upc": null,
"updated_at": Any<String>,
"weight": null,
"width": null,
},
],
"weight": null,
"width": null,
}
`;
exports[`/admin/products POST /admin/products updates a product (update prices, tags, update status, delete collection, delete type, replaces images) 1`] = `
Object {
"collection": null,
"collection_id": null,
"created_at": Any<String>,
"deleted_at": null,
"description": "test-product-description",
"discountable": true,
"external_id": null,
"handle": "test-product",
"height": null,
"hs_code": null,
"id": "test-product",
"images": Array [
Object {
"created_at": Any<String>,
"deleted_at": null,
"id": StringMatching /\\^img_\\*/,
"metadata": null,
"updated_at": Any<String>,
"url": "test-image-2.png",
},
],
"is_giftcard": false,
"length": null,
"material": null,
"metadata": null,
"mid_code": null,
"options": Array [
Object {
"created_at": Any<String>,
"deleted_at": null,
"id": "test-option",
"metadata": null,
"product_id": "test-product",
"title": "test-option",
"updated_at": Any<String>,
},
],
"origin_country": null,
"profile_id": StringMatching /\\^sp_\\*/,
"status": "published",
"subtitle": null,
"tags": Array [
Object {
"created_at": Any<String>,
"deleted_at": null,
"id": "tag1",
"metadata": null,
"updated_at": Any<String>,
"value": "123",
},
],
"thumbnail": "test-image-2.png",
"title": "Test product",
"type": Object {
"created_at": Any<String>,
"deleted_at": null,
"id": StringMatching /\\^ptyp_\\*/,
"metadata": null,
"updated_at": Any<String>,
"value": "test-type-2",
},
"type_id": StringMatching /\\^ptyp_\\*/,
"updated_at": Any<String>,
"variants": Array [
Object {
"allow_backorder": false,
"barcode": "test-barcode",
"created_at": Any<String>,
"deleted_at": null,
"ean": "test-ean",
"height": null,
"hs_code": null,
"id": "test-variant",
"inventory_quantity": 10,
"length": null,
"manage_inventory": true,
"material": null,
"metadata": null,
"mid_code": null,
"options": Array [
Object {
"created_at": Any<String>,
"deleted_at": null,
"id": "test-variant-option",
"metadata": null,
"option_id": "test-option",
"updated_at": Any<String>,
"value": "Default variant",
"variant_id": "test-variant",
},
],
"origin_country": null,
"prices": Array [
Object {
"amount": 75,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": "test-price",
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
],
"product_id": "test-product",
"sku": "test-sku",
"title": "Test variant",
"upc": "test-upc",
"updated_at": Any<String>,
"weight": null,
"width": null,
},
],
"weight": null,
"width": null,
}
`;
@@ -0,0 +1,758 @@
const path = require("path")
const setupServer = require("../../../helpers/setup-server")
const { useApi } = require("../../../helpers/use-api")
const { useDb, initDb } = require("../../../helpers/use-db")
const adminSeeder = require("../../helpers/admin-seeder")
const customerSeeder = require("../../helpers/customer-seeder")
const priceListSeeder = require("../../helpers/price-list-seeder")
const productSeeder = require("../../helpers/product-seeder")
jest.setTimeout(30000)
describe("/admin/price-lists", () => {
let medusaProcess
let dbConnection
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd })
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
describe("POST /admin/price-list", () => {
beforeEach(async () => {
try {
await adminSeeder(dbConnection)
await customerSeeder(dbConnection)
await productSeeder(dbConnection)
} catch (err) {
console.log(err)
throw err
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("creates a price list", async () => {
const api = useApi()
const payload = {
name: "VIP Summer sale",
description: "Summer sale for VIP customers. 25% off selected items.",
type: "sale",
status: "active",
starts_at: "2022-07-01T00:00:00.000Z",
ends_at: "2022-07-31T00:00:00.000Z",
customer_groups: [
{
id: "customer-group-1",
},
],
prices: [
{
amount: 85,
currency_code: "usd",
variant_id: "test-variant",
},
],
}
const response = await api
.post("/admin/price-lists", payload, {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.warn(err.response.data)
})
expect(response.status).toEqual(200)
expect(response.data.price_list).toEqual(
expect.objectContaining({
id: expect.any(String),
name: "VIP Summer sale",
description: "Summer sale for VIP customers. 25% off selected items.",
type: "sale",
status: "active",
starts_at: "2022-07-01T00:00:00.000Z",
ends_at: "2022-07-31T00:00:00.000Z",
customer_groups: [
expect.objectContaining({
id: "customer-group-1",
}),
],
prices: [
expect.objectContaining({
id: expect.any(String),
amount: 85,
currency_code: "usd",
variant_id: "test-variant",
}),
],
})
)
})
})
describe("GET /admin/price-lists", () => {
beforeEach(async () => {
try {
await adminSeeder(dbConnection)
await productSeeder(dbConnection)
await priceListSeeder(dbConnection)
} catch (err) {
console.log(err)
throw err
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("returns a price list by :id", async () => {
const api = useApi()
const response = await api
.get("/admin/price-lists/pl_no_customer_groups", {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.warn(err.response.data)
})
expect(response.status).toEqual(200)
expect(response.data.price_list).toMatchSnapshot({
id: expect.any(String),
name: "VIP winter sale",
description: "Winter sale for VIP customers. 25% off selected items.",
type: "sale",
status: "active",
starts_at: "2022-07-01T00:00:00.000Z",
ends_at: "2022-07-31T00:00:00.000Z",
prices: [
{
id: expect.any(String),
amount: 100,
currency_code: "usd",
min_quantity: 1,
max_quantity: 100,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: expect.any(String),
amount: 80,
currency_code: "usd",
min_quantity: 101,
max_quantity: 500,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: expect.any(String),
amount: 50,
currency_code: "usd",
min_quantity: 501,
max_quantity: 1000,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
created_at: expect.any(String),
updated_at: expect.any(String),
},
],
created_at: expect.any(String),
updated_at: expect.any(String),
})
})
it("returns a list of price lists", async () => {
const api = useApi()
const response = await api
.get("/admin/price-lists", {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.warn(err.response.data)
})
expect(response.status).toEqual(200)
expect(response.data.price_lists).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "pl_no_customer_groups",
}),
])
)
})
})
describe("POST /admin/price-lists/:id", () => {
beforeEach(async () => {
try {
await adminSeeder(dbConnection)
await customerSeeder(dbConnection)
await productSeeder(dbConnection)
await priceListSeeder(dbConnection)
} catch (err) {
console.log(err)
throw err
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("updates a price list", async () => {
const api = useApi()
const payload = {
name: "Loyalty Reward - Winter Sale",
description: "Winter sale for our most loyal customers",
type: "sale",
status: "draft",
starts_at: "2022-09-01T00:00:00.000Z",
ends_at: "2022-12-31T00:00:00.000Z",
customer_groups: [
{
id: "customer-group-1",
},
],
prices: [
{
amount: 85,
currency_code: "usd",
variant_id: "test-variant_1",
},
{
amount: 10,
currency_code: "usd",
variant_id: "test-variant",
},
],
}
const response = await api
.post("/admin/price-lists/pl_no_customer_groups", payload, {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.warn(err.response.data)
})
expect(response.status).toEqual(200)
expect(response.data.price_list).toMatchSnapshot({
id: "pl_no_customer_groups",
name: "Loyalty Reward - Winter Sale",
description: "Winter sale for our most loyal customers",
type: "sale",
status: "draft",
starts_at: "2022-09-01T00:00:00.000Z",
ends_at: "2022-12-31T00:00:00.000Z",
prices: [
{
id: expect.any(String),
amount: 100,
currency_code: "usd",
min_quantity: 1,
max_quantity: 100,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
region_id: null,
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: expect.any(String),
amount: 80,
currency_code: "usd",
min_quantity: 101,
max_quantity: 500,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
region_id: null,
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: expect.any(String),
amount: 50,
currency_code: "usd",
min_quantity: 501,
max_quantity: 1000,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
region_id: null,
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: expect.any(String),
amount: 85,
currency_code: "usd",
variant_id: "test-variant_1",
price_list_id: "pl_no_customer_groups",
min_quantity: null,
max_quantity: null,
region_id: null,
created_at: expect.any(String),
updated_at: expect.any(String),
deleted_at: null,
},
{
id: expect.any(String),
amount: 10,
currency_code: "usd",
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
min_quantity: null,
max_quantity: null,
region_id: null,
created_at: expect.any(String),
updated_at: expect.any(String),
deleted_at: null,
},
],
customer_groups: [
{
id: "customer-group-1",
created_at: expect.any(String),
updated_at: expect.any(String),
},
],
created_at: expect.any(String),
updated_at: expect.any(String),
})
})
it("updates the amount and currency of a price in the price list", async () => {
const api = useApi()
const payload = {
prices: [
{
id: "ma_test_1",
amount: 250,
currency_code: "eur",
variant_id: "test-variant",
},
],
}
const response = await api
.post("/admin/price-lists/pl_no_customer_groups", payload, {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.warn(err.response.data)
})
expect(response.status).toEqual(200)
const updatedPrice = response.data.price_list.prices.find(
(p) => p.id === "ma_test_1"
)
expect(updatedPrice).toMatchSnapshot({
id: "ma_test_1",
amount: 250,
currency_code: "eur",
min_quantity: 1,
max_quantity: 100,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
region_id: null,
created_at: expect.any(String),
updated_at: expect.any(String),
})
})
})
describe("POST /admin/price-lists/:id/prices/batch", () => {
beforeEach(async () => {
try {
await adminSeeder(dbConnection)
await productSeeder(dbConnection)
await priceListSeeder(dbConnection)
} catch (err) {
console.log(err)
throw err
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("Adds a batch of new prices to a price list without overriding existing prices", async () => {
const api = useApi()
const payload = {
prices: [
{
amount: 45,
currency_code: "usd",
variant_id: "test-variant",
min_quantity: 1001,
max_quantity: 2000,
},
{
amount: 35,
currency_code: "usd",
variant_id: "test-variant",
min_quantity: 2001,
max_quantity: 3000,
},
{
amount: 25,
currency_code: "usd",
variant_id: "test-variant",
min_quantity: 3001,
max_quantity: 4000,
},
],
}
const response = await api
.post(
"/admin/price-lists/pl_no_customer_groups/prices/batch",
payload,
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.warn(err.response.data)
})
expect(response.status).toEqual(200)
expect(response.data.price_list.prices.length).toEqual(6)
expect(response.data.price_list.prices).toMatchSnapshot([
{
id: expect.any(String),
price_list_id: "pl_no_customer_groups",
amount: 100,
currency_code: "usd",
min_quantity: 1,
max_quantity: 100,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: expect.any(String),
price_list_id: "pl_no_customer_groups",
amount: 80,
currency_code: "usd",
min_quantity: 101,
max_quantity: 500,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: expect.any(String),
price_list_id: "pl_no_customer_groups",
amount: 50,
currency_code: "usd",
min_quantity: 501,
max_quantity: 1000,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: expect.any(String),
price_list_id: "pl_no_customer_groups",
amount: 45,
currency_code: "usd",
variant_id: "test-variant",
min_quantity: 1001,
max_quantity: 2000,
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: expect.any(String),
price_list_id: "pl_no_customer_groups",
amount: 35,
currency_code: "usd",
variant_id: "test-variant",
min_quantity: 2001,
max_quantity: 3000,
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: expect.any(String),
price_list_id: "pl_no_customer_groups",
amount: 25,
currency_code: "usd",
variant_id: "test-variant",
min_quantity: 3001,
max_quantity: 4000,
created_at: expect.any(String),
updated_at: expect.any(String),
},
])
})
it("Adds a batch of new prices to a price list overriding existing prices", async () => {
const api = useApi()
const payload = {
prices: [
{
amount: 45,
currency_code: "usd",
variant_id: "test-variant",
min_quantity: 1001,
max_quantity: 2000,
},
{
amount: 35,
currency_code: "usd",
variant_id: "test-variant",
min_quantity: 2001,
max_quantity: 3000,
},
{
amount: 25,
currency_code: "usd",
variant_id: "test-variant",
min_quantity: 3001,
max_quantity: 4000,
},
],
override: true,
}
const response = await api
.post(
"/admin/price-lists/pl_no_customer_groups/prices/batch",
payload,
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.warn(err.response.data)
})
expect(response.status).toEqual(200)
expect(response.data.price_list.prices.length).toEqual(3)
expect(response.data.price_list.prices).toMatchSnapshot([
{
id: expect.any(String),
price_list_id: "pl_no_customer_groups",
amount: 45,
currency_code: "usd",
variant_id: "test-variant",
min_quantity: 1001,
max_quantity: 2000,
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: expect.any(String),
price_list_id: "pl_no_customer_groups",
amount: 35,
currency_code: "usd",
variant_id: "test-variant",
min_quantity: 2001,
max_quantity: 3000,
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: expect.any(String),
price_list_id: "pl_no_customer_groups",
amount: 25,
currency_code: "usd",
variant_id: "test-variant",
min_quantity: 3001,
max_quantity: 4000,
created_at: expect.any(String),
updated_at: expect.any(String),
},
])
})
})
describe("DELETE /admin/price-lists/:id", () => {
beforeEach(async () => {
try {
await adminSeeder(dbConnection)
await productSeeder(dbConnection)
await priceListSeeder(dbConnection)
} catch (err) {
console.log(err)
throw err
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("Deletes a price list", async () => {
const api = useApi()
const response = await api
.delete("/admin/price-lists/pl_no_customer_groups", {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.warn(err.response.data)
})
expect(response.status).toEqual(200)
expect(response.data).toEqual({
id: "pl_no_customer_groups",
object: "price-list",
deleted: true,
})
})
})
describe("tests cascade on delete", () => {
beforeEach(async () => {
try {
await adminSeeder(dbConnection)
await productSeeder(dbConnection)
await priceListSeeder(dbConnection)
} catch (err) {
console.log(err)
throw err
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("Deletes a variant and ensures that prices associated with the variant are deleted from PriceList", async () => {
const api = useApi()
const deleteResponse = await api
.delete("/admin/products/test-product/variants/test-variant", {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.warn(err.response.data)
})
const response = await api.get(
"/admin/price-lists/pl_no_customer_groups",
{
headers: {
Authorization: "Bearer test_token",
},
}
)
expect(response.status).toEqual(200)
expect(response.data.price_list.prices.length).toEqual(0)
})
})
describe("DELETE /admin/price-lists/:id/prices/batch", () => {
beforeEach(async () => {
try {
await adminSeeder(dbConnection)
await productSeeder(dbConnection)
await priceListSeeder(dbConnection)
} catch (err) {
console.log(err)
throw err
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("Deletes several prices associated with a price list", async () => {
const api = useApi()
const response = await api
.delete("/admin/price-lists/pl_no_customer_groups/prices/batch", {
headers: {
Authorization: "Bearer test_token",
},
data: {
price_ids: ["ma_test_1", "ma_test_2"],
},
})
.catch((err) => {
console.warn(err.response.data)
})
const getPriceListResponse = await api
.get("/admin/price-lists/pl_no_customer_groups", {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.warn(err.response.data)
})
expect(response.status).toEqual(200)
expect(response.data).toEqual({
ids: ["ma_test_1", "ma_test_2"],
object: "money-amount",
deleted: true,
})
expect(getPriceListResponse.data.price_list.prices.length).toEqual(1)
expect(getPriceListResponse.data.price_list.prices[0].id).toEqual(
"ma_test_3"
)
})
})
})
+378 -134
View File
@@ -7,6 +7,7 @@ const { initDb, useDb } = require("../../../helpers/use-db")
const adminSeeder = require("../../helpers/admin-seeder")
const productSeeder = require("../../helpers/product-seeder")
const { ProductVariant } = require("@medusajs/medusa")
const priceListSeeder = require("../../helpers/price-list-seeder")
jest.setTimeout(50000)
@@ -773,7 +774,20 @@ describe("/admin/products", () => {
{
title: "Test variant",
inventory_quantity: 10,
prices: [{ currency_code: "usd", amount: 100 }],
prices: [
{
currency_code: "usd",
amount: 100,
},
{
currency_code: "eur",
amount: 45,
},
{
currency_code: "dkk",
amount: 30,
},
],
options: [{ value: "large" }, { value: "green" }],
},
],
@@ -790,66 +804,126 @@ describe("/admin/products", () => {
})
expect(response.status).toEqual(200)
expect(response.data.product).toEqual(
expect.objectContaining({
title: "Test",
discountable: true,
is_giftcard: false,
handle: "test",
status: "draft",
images: expect.arrayContaining([
expect.objectContaining({
url: "test-image.png",
}),
expect.objectContaining({
url: "test-image-2.png",
}),
]),
thumbnail: "test-image.png",
tags: [
expect.objectContaining({
value: "123",
}),
expect.objectContaining({
value: "456",
}),
],
type: expect.objectContaining({
value: "test-type",
}),
collection: expect.objectContaining({
id: "test-collection",
title: "Test collection",
}),
options: [
expect.objectContaining({
title: "size",
}),
expect.objectContaining({
title: "color",
}),
],
variants: [
expect.objectContaining({
title: "Test variant",
prices: [
expect.objectContaining({
currency_code: "usd",
amount: 100,
}),
],
options: [
expect.objectContaining({
value: "large",
}),
expect.objectContaining({
value: "green",
}),
],
}),
],
})
)
expect(response.data.product).toMatchSnapshot({
id: expect.stringMatching(/^prod_*/),
title: "Test",
discountable: true,
is_giftcard: false,
handle: "test",
status: "draft",
created_at: expect.any(String),
updated_at: expect.any(String),
profile_id: expect.stringMatching(/^sp_*/),
images: [
{
id: expect.any(String),
url: "test-image.png",
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: expect.any(String),
url: "test-image-2.png",
created_at: expect.any(String),
updated_at: expect.any(String),
},
],
thumbnail: "test-image.png",
tags: [
{
id: expect.any(String),
value: "123",
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: expect.any(String),
value: "456",
created_at: expect.any(String),
updated_at: expect.any(String),
},
],
type: {
value: "test-type",
created_at: expect.any(String),
updated_at: expect.any(String),
},
collection: {
id: "test-collection",
title: "Test collection",
created_at: expect.any(String),
updated_at: expect.any(String),
},
options: [
{
id: expect.stringMatching(/^opt_*/),
product_id: expect.stringMatching(/^prod_*/),
title: "size",
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: expect.stringMatching(/^opt_*/),
product_id: expect.stringMatching(/^prod_*/),
title: "color",
created_at: expect.any(String),
updated_at: expect.any(String),
},
],
variants: [
{
id: expect.stringMatching(/^variant_*/),
product_id: expect.stringMatching(/^prod_*/),
updated_at: expect.any(String),
created_at: expect.any(String),
title: "Test variant",
prices: [
{
id: expect.stringMatching(/^ma_*/),
currency_code: "usd",
amount: 100,
created_at: expect.any(String),
updated_at: expect.any(String),
variant_id: expect.stringMatching(/^variant_*/),
},
{
id: expect.stringMatching(/^ma_*/),
currency_code: "eur",
amount: 45,
created_at: expect.any(String),
updated_at: expect.any(String),
variant_id: expect.stringMatching(/^variant_*/),
},
{
id: expect.stringMatching(/^ma_*/),
currency_code: "dkk",
amount: 30,
created_at: expect.any(String),
updated_at: expect.any(String),
variant_id: expect.stringMatching(/^variant_*/),
},
],
options: [
{
value: "large",
created_at: expect.any(String),
updated_at: expect.any(String),
variant_id: expect.stringMatching(/^variant_*/),
option_id: expect.stringMatching(/^opt_*/),
id: expect.stringMatching(/^optval_*/),
},
{
value: "green",
created_at: expect.any(String),
updated_at: expect.any(String),
variant_id: expect.stringMatching(/^variant_*/),
option_id: expect.stringMatching(/^opt_*/),
id: expect.stringMatching(/^optval_*/),
},
],
},
],
})
})
it("creates a product that is not discountable", async () => {
@@ -1006,8 +1080,7 @@ describe("/admin/products", () => {
prices: [
{
currency_code: "usd",
amount: 100,
sale_amount: 75,
amount: 75,
},
],
},
@@ -1030,36 +1103,92 @@ describe("/admin/products", () => {
expect(response.status).toEqual(200)
expect(response.data.product).toEqual(
expect.objectContaining({
images: expect.arrayContaining([
expect.objectContaining({
url: "test-image-2.png",
}),
]),
thumbnail: "test-image-2.png",
tags: [
expect.objectContaining({
value: "123",
}),
],
variants: [
expect.objectContaining({
prices: [
expect.objectContaining({
sale_amount: 75,
amount: 100,
}),
],
}),
],
status: "published",
collection: null,
type: expect.objectContaining({
value: "test-type-2",
}),
})
)
expect(response.data.product).toMatchSnapshot({
id: "test-product",
created_at: expect.any(String),
description: "test-product-description",
discountable: true,
handle: "test-product",
images: [
{
created_at: expect.any(String),
deleted_at: null,
id: expect.stringMatching(/^img_*/),
metadata: null,
updated_at: expect.any(String),
url: "test-image-2.png",
},
],
is_giftcard: false,
options: [
{
created_at: expect.any(String),
id: "test-option",
product_id: "test-product",
title: "test-option",
updated_at: expect.any(String),
},
],
profile_id: expect.stringMatching(/^sp_*/),
status: "published",
tags: [
{
created_at: expect.any(String),
id: "tag1",
updated_at: expect.any(String),
value: "123",
},
],
thumbnail: "test-image-2.png",
title: "Test product",
type: {
created_at: expect.any(String),
id: expect.stringMatching(/^ptyp_*/),
updated_at: expect.any(String),
value: "test-type-2",
},
type_id: expect.stringMatching(/^ptyp_*/),
updated_at: expect.any(String),
variants: [
{
allow_backorder: false,
barcode: "test-barcode",
created_at: expect.any(String),
ean: "test-ean",
id: "test-variant",
inventory_quantity: 10,
manage_inventory: true,
options: [
{
created_at: expect.any(String),
deleted_at: null,
id: "test-variant-option",
metadata: null,
option_id: "test-option",
updated_at: expect.any(String),
value: "Default variant",
variant_id: "test-variant",
},
],
origin_country: null,
prices: [
{
amount: 75,
created_at: expect.any(String),
currency_code: "usd",
id: "test-price",
updated_at: expect.any(String),
variant_id: "test-variant",
},
],
product_id: "test-product",
sku: "test-sku",
title: "Test variant",
upc: "test-upc",
updated_at: expect.any(String),
},
],
})
})
it("updates product (removes images when empty array included)", async () => {
@@ -1189,11 +1318,12 @@ describe("/admin/products", () => {
})
})
describe("updates a variant's prices", () => {
describe("updates a variant's default prices (ignores prices associated with a Price List)", () => {
beforeEach(async () => {
try {
await productSeeder(dbConnection)
await adminSeeder(dbConnection)
await priceListSeeder(dbConnection)
} catch (err) {
console.log(err)
throw err
@@ -1205,7 +1335,7 @@ describe("/admin/products", () => {
await db.teardown()
})
it("successfully updates a variant's prices by changing an existing price (currency_code)", async () => {
it("successfully updates a variant's default prices by changing an existing price (currency_code)", async () => {
const api = useApi()
const data = {
prices: [
@@ -1239,6 +1369,33 @@ describe("/admin/products", () => {
amount: 1500,
currency_code: "usd",
}),
expect.objectContaining({
id: "ma_test_1",
amount: 100,
currency_code: "usd",
min_quantity: 1,
max_quantity: 100,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
}),
expect.objectContaining({
id: "ma_test_2",
amount: 80,
currency_code: "usd",
min_quantity: 101,
max_quantity: 500,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
}),
expect.objectContaining({
id: "ma_test_3",
amount: 50,
currency_code: "usd",
min_quantity: 501,
max_quantity: 1000,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
}),
]),
}),
]),
@@ -1316,26 +1473,55 @@ describe("/admin/products", () => {
expect(response.status).toEqual(200)
expect(response.data).toEqual({
product: expect.objectContaining({
id: "test-product",
variants: expect.arrayContaining([
expect.objectContaining({
id: "test-variant",
prices: [
expect.objectContaining({
amount: 100,
currency_code: "usd",
}),
expect.objectContaining({
amount: 4500,
currency_code: "eur",
}),
],
}),
]),
}),
})
expect(response.data).toEqual(
expect.objectContaining({
product: expect.objectContaining({
id: "test-product",
variants: expect.arrayContaining([
expect.objectContaining({
id: "test-variant",
prices: expect.arrayContaining([
expect.objectContaining({
amount: 100,
currency_code: "usd",
}),
expect.objectContaining({
amount: 4500,
currency_code: "eur",
}),
expect.objectContaining({
id: "ma_test_1",
amount: 100,
currency_code: "usd",
min_quantity: 1,
max_quantity: 100,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
}),
expect.objectContaining({
id: "ma_test_2",
amount: 80,
currency_code: "usd",
min_quantity: 101,
max_quantity: 500,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
}),
expect.objectContaining({
id: "ma_test_3",
amount: 50,
currency_code: "usd",
min_quantity: 501,
max_quantity: 1000,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
}),
]),
}),
]),
}),
})
)
})
it("successfully updates a variant's prices by replacing a price", async () => {
@@ -1343,7 +1529,7 @@ describe("/admin/products", () => {
const data = {
prices: [
{
currency_code: "eur",
currency_code: "usd",
amount: 4500,
},
],
@@ -1362,14 +1548,43 @@ describe("/admin/products", () => {
expect(response.status).toEqual(200)
expect(response.data.product.variants[0].prices.length).toEqual(
data.prices.length
4 // 3 prices from Price List + 1 default price
)
expect(response.data.product.variants[0].prices).toEqual(
expect.arrayContaining([
expect.objectContaining({
amount: 4500,
currency_code: "usd",
}),
expect.objectContaining({
id: "ma_test_1",
amount: 100,
currency_code: "usd",
min_quantity: 1,
max_quantity: 100,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
}),
expect.objectContaining({
id: "ma_test_2",
amount: 80,
currency_code: "usd",
min_quantity: 101,
max_quantity: 500,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
}),
expect.objectContaining({
id: "ma_test_3",
amount: 50,
currency_code: "usd",
min_quantity: 501,
max_quantity: 1000,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
}),
])
)
expect(response.data.product.variants[0].prices).toEqual([
expect.objectContaining({
amount: 4500,
currency_code: "eur",
}),
])
})
it("successfully updates a variant's prices by deleting a price and adding another price", async () => {
@@ -1400,18 +1615,47 @@ describe("/admin/products", () => {
expect(response.status).toEqual(200)
expect(response.data.product.variants[0].prices.length).toEqual(
data.prices.length
5 // 2 default prices + 3 prices from Price List
)
expect(response.data.product.variants[0].prices).toEqual(
expect.arrayContaining([
expect.objectContaining({
amount: 8000,
currency_code: "dkk",
}),
expect.objectContaining({
amount: 900,
currency_code: "eur",
}),
expect.objectContaining({
id: "ma_test_1",
amount: 100,
currency_code: "usd",
min_quantity: 1,
max_quantity: 100,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
}),
expect.objectContaining({
id: "ma_test_2",
amount: 80,
currency_code: "usd",
min_quantity: 101,
max_quantity: 500,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
}),
expect.objectContaining({
id: "ma_test_3",
amount: 50,
currency_code: "usd",
min_quantity: 501,
max_quantity: 1000,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
}),
])
)
expect(response.data.product.variants[0].prices).toEqual([
expect.objectContaining({
amount: 8000,
currency_code: "dkk",
}),
expect.objectContaining({
amount: 900,
currency_code: "eur",
}),
])
})
it("successfully updates a variant's prices by updating an existing price (using region_id) and adding another price", async () => {
@@ -32,8 +32,8 @@ describe("/admin/store", () => {
afterEach(async () => {
const db = useDb()
db.teardown()
medusaProcess.kill()
await db.teardown()
await medusaProcess.kill()
})
it("has created store with default currency", async () => {
@@ -37,8 +37,10 @@ Object {
"currency_code": "usd",
"deleted_at": null,
"id": "test-price",
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"sale_amount": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
@@ -93,8 +95,10 @@ Object {
"currency_code": "usd",
"deleted_at": null,
"id": "test-price",
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"sale_amount": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
@@ -150,8 +154,10 @@ Object {
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"sale_amount": null,
"updated_at": Any<String>,
"variant_id": Any<String>,
},
@@ -162,8 +162,10 @@ Object {
"currency_code": "usd",
"deleted_at": null,
"id": "test-price",
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"sale_amount": null,
"updated_at": Any<String>,
"variant_id": "test-variant",
},
@@ -211,8 +213,10 @@ Object {
"currency_code": "usd",
"deleted_at": null,
"id": "test-price2",
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"sale_amount": null,
"updated_at": Any<String>,
"variant_id": "test-variant_2",
},
@@ -260,8 +264,10 @@ Object {
"currency_code": "usd",
"deleted_at": null,
"id": "test-price1",
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"sale_amount": null,
"updated_at": Any<String>,
"variant_id": "test-variant_1",
},
@@ -314,8 +320,10 @@ Object {
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"max_quantity": null,
"min_quantity": null,
"price_list_id": null,
"region_id": null,
"sale_amount": null,
"updated_at": Any<String>,
"variant_id": Any<String>,
},
@@ -99,7 +99,9 @@ describe("/store/variants", () => {
deleted_at: null,
id: "test-price",
region_id: null,
sale_amount: null,
min_quantity: null,
max_quantity: null,
price_list_id: null,
variant_id: "test-variant",
},
],
@@ -193,7 +195,9 @@ describe("/store/variants", () => {
deleted_at: null,
id: "test-price",
region_id: null,
sale_amount: null,
min_quantity: null,
max_quantity: null,
price_list_id: null,
variant_id: "test-variant",
},
],
@@ -353,7 +353,9 @@ describe("/store/products", () => {
deleted_at: null,
id: "test-price",
region_id: null,
sale_amount: null,
min_quantity: null,
max_quantity: null,
price_list_id: null,
updated_at: expect.any(String),
variant_id: "test-variant",
},
@@ -396,7 +398,9 @@ describe("/store/products", () => {
deleted_at: null,
id: "test-price2",
region_id: null,
sale_amount: null,
min_quantity: null,
max_quantity: null,
price_list_id: null,
variant_id: "test-variant_2",
},
],
@@ -438,7 +442,9 @@ describe("/store/products", () => {
deleted_at: null,
id: "test-price1",
region_id: null,
sale_amount: null,
min_quantity: null,
max_quantity: null,
price_list_id: null,
updated_at: expect.any(String),
variant_id: "test-variant_1",
},
@@ -36,17 +36,17 @@ module.exports = async (connection, data = {}) => {
has_account: true,
})
const customer5 = manager.create(Customer, {
const customer5 = await manager.create(Customer, {
id: "test-customer-5",
email: "test5@email.com",
})
const customer6 = manager.create(Customer, {
const customer6 = await manager.create(Customer, {
id: "test-customer-6",
email: "test6@email.com",
})
const customer7 = manager.create(Customer, {
const customer7 = await manager.create(Customer, {
id: "test-customer-7",
email: "test7@email.com",
})
@@ -78,13 +78,13 @@ module.exports = async (connection, data = {}) => {
name: "test-group-4",
})
const c_group_5 = manager.create(CustomerGroup, {
const c_group_5 = await manager.create(CustomerGroup, {
id: "test-group-5",
name: "test-group-5",
})
await manager.save(c_group_5)
const c_group_6 = manager.create(CustomerGroup, {
const c_group_6 = await manager.create(CustomerGroup, {
id: "test-group-6",
name: "test-group-6",
})
@@ -99,7 +99,7 @@ module.exports = async (connection, data = {}) => {
customer7.groups = [c_group_5, c_group_6]
await manager.save(customer7)
const c_group_delete = manager.create(CustomerGroup, {
const c_group_delete = await manager.create(CustomerGroup, {
id: "test-group-delete",
name: "test-group-delete",
})
@@ -0,0 +1,53 @@
const { PriceList, MoneyAmount } = require("@medusajs/medusa")
module.exports = async (connection, data = {}) => {
const manager = connection.manager
const priceListNoCustomerGroups = await manager.create(PriceList, {
id: "pl_no_customer_groups",
name: "VIP winter sale",
description: "Winter sale for VIP customers. 25% off selected items.",
type: "sale",
status: "active",
starts_at: "2022-07-01T00:00:00.000Z",
ends_at: "2022-07-31T00:00:00.000Z",
})
await manager.save(priceListNoCustomerGroups)
const moneyAmount1 = await manager.create(MoneyAmount, {
id: "ma_test_1",
amount: 100,
currency_code: "usd",
min_quantity: 1,
max_quantity: 100,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
})
await manager.save(moneyAmount1)
const moneyAmount2 = await manager.create(MoneyAmount, {
id: "ma_test_2",
amount: 80,
currency_code: "usd",
min_quantity: 101,
max_quantity: 500,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
})
await manager.save(moneyAmount2)
const moneyAmount3 = await manager.create(MoneyAmount, {
id: "ma_test_3",
amount: 50,
currency_code: "usd",
min_quantity: 501,
max_quantity: 1000,
variant_id: "test-variant",
price_list_id: "pl_no_customer_groups",
})
await manager.save(moneyAmount3)
}
+3 -3
View File
@@ -8,16 +8,16 @@
"build": "babel src -d dist --extensions \".ts,.js\""
},
"dependencies": {
"@medusajs/medusa": "1.2.0-dev-1646213107704",
"@medusajs/medusa": "1.2.0-dev-1647336201011",
"faker": "^5.5.3",
"medusa-interfaces": "1.2.0-dev-1646213107704",
"medusa-interfaces": "1.2.0-dev-1647336201011",
"typeorm": "^0.2.31"
},
"devDependencies": {
"@babel/cli": "^7.12.10",
"@babel/core": "^7.12.10",
"@babel/node": "^7.12.10",
"babel-preset-medusa-package": "1.1.19-dev-1646213107704",
"babel-preset-medusa-package": "1.1.19-dev-1647336201011",
"jest": "^26.6.3"
}
}
+35 -35
View File
@@ -1256,10 +1256,10 @@
"@types/yargs" "^15.0.0"
chalk "^4.0.0"
"@medusajs/medusa-cli@1.2.0-dev-1646213107704":
version "1.2.0-dev-1646213107704"
resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.2.0-dev-1646213107704.tgz#b84f9143450f3c03e732277b6c5bbeb0df761b2e"
integrity sha512-U1BqPe167vxpzf2YfopVpNTq0qcBkX3jkWFCNduHEjkBbuOXchlB/MheKtxIsxjHV1hzmjTr9uw3owu93pDYCQ==
"@medusajs/medusa-cli@1.2.0-dev-1647336201011":
version "1.2.0-dev-1647336201011"
resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.2.0-dev-1647336201011.tgz#9842f8d2f53d5a7d2207ec23d7d4b18a33e65c54"
integrity sha512-wVqcWMh0+8ko/Db66GDrp68XpaVkvPrjrc4mn7KdfD9DnIgFCOoFakuYsQUxHDNak9EA96idZSF8/hsHALlOqg==
dependencies:
"@babel/polyfill" "^7.8.7"
"@babel/runtime" "^7.9.6"
@@ -1277,8 +1277,8 @@
is-valid-path "^0.1.1"
joi-objectid "^3.0.1"
meant "^1.0.1"
medusa-core-utils "1.1.31-dev-1646213107704"
medusa-telemetry "0.0.11-dev-1646213107704"
medusa-core-utils "1.1.31-dev-1647336201011"
medusa-telemetry "0.0.11-dev-1647336201011"
netrc-parser "^3.1.6"
open "^8.0.6"
ora "^5.4.1"
@@ -1292,13 +1292,13 @@
winston "^3.3.3"
yargs "^15.3.1"
"@medusajs/medusa@1.2.0-dev-1646213107704":
version "1.2.0-dev-1646213107704"
resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.2.0-dev-1646213107704.tgz#40ed0efe4f8e242c620eb1a984f0904465576920"
integrity sha512-pZnoM/U6WF4IdKriX7lKaxlmt9nlK4WoQ4PtzLjvZemjNHKriLjsg8ugvuBOuuzZJe66O8XEAXcoPh2o/SmAeg==
"@medusajs/medusa@1.2.0-dev-1647336201011":
version "1.2.0-dev-1647336201011"
resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.2.0-dev-1647336201011.tgz#76c0c3b93adf55b463ec091d2e144616dc1293bf"
integrity sha512-t0eWo60Y5KsyN4ucWDjPyuI+4oOHrwqlrzegg4oogGiAfvq2rLDQFwxrfvzKlhgYtJUA58dvxMpZqG5n1NOJLQ==
dependencies:
"@hapi/joi" "^16.1.8"
"@medusajs/medusa-cli" "1.2.0-dev-1646213107704"
"@medusajs/medusa-cli" "1.2.0-dev-1647336201011"
"@types/lodash" "^4.14.168"
awilix "^4.2.3"
body-parser "^1.19.0"
@@ -1322,8 +1322,8 @@
joi "^17.3.0"
joi-objectid "^3.0.1"
jsonwebtoken "^8.5.1"
medusa-core-utils "1.1.31-dev-1646213107704"
medusa-test-utils "1.1.37-dev-1646213107704"
medusa-core-utils "1.1.31-dev-1647336201011"
medusa-test-utils "1.1.37-dev-1647336201011"
morgan "^1.9.1"
multer "^1.4.2"
passport "^0.4.0"
@@ -1947,10 +1947,10 @@ babel-preset-jest@^26.6.2:
babel-plugin-jest-hoist "^26.6.2"
babel-preset-current-node-syntax "^1.0.0"
babel-preset-medusa-package@1.1.19-dev-1646213107704:
version "1.1.19-dev-1646213107704"
resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.19-dev-1646213107704.tgz#820201a6abfbdb95af623f1cae8981bb5ccddb8d"
integrity sha512-gTkFWvA/i+UmPSK8O0J4HBXE1tqQSD/D2p3ernvcvoaaXYhiLhNUrRRe9F8YXdkFT4tVXy2DKz/eQyUG1dsnnA==
babel-preset-medusa-package@1.1.19-dev-1647336201011:
version "1.1.19-dev-1647336201011"
resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.19-dev-1647336201011.tgz#52a3e7ad80f216cdcae3732c22d9edb3d2504a92"
integrity sha512-oYbWdvcXHvGc14p6N4vyGjPK0gs/zO4gVMahrDHPcMGfmGOajkVBMnuMfiEzOaDHaiBchIsWCv9LYLFLcv8JjA==
dependencies:
"@babel/plugin-proposal-class-properties" "^7.12.1"
"@babel/plugin-proposal-decorators" "^7.12.1"
@@ -5140,25 +5140,25 @@ media-typer@0.3.0:
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
medusa-core-utils@1.1.31-dev-1646213107704:
version "1.1.31-dev-1646213107704"
resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.31-dev-1646213107704.tgz#40ca7c1a4b290b1af9cf855cfc3320c23bbf3c53"
integrity sha512-P3D/jVSnx0YYEU8hTVqEow4Xht/BAHnHyc/HSbU5YT7PtCwEd+MY7+bvSAkhbVXNDCVsxyGTDA2nuofPS/c4RQ==
medusa-core-utils@1.1.31-dev-1647336201011:
version "1.1.31-dev-1647336201011"
resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.31-dev-1647336201011.tgz#6b6d9c9708d8377d1bddc8ef9d26723b550312c9"
integrity sha512-/qYyS0vE5lqHhNZfhNM2iTE5Sh8qYockVyHn2dapolBsmYE9f7IjhOsB3bz2o0b1J33L3se6iG0wSCQeN3INXA==
dependencies:
joi "^17.3.0"
joi-objectid "^3.0.1"
medusa-interfaces@1.2.0-dev-1646213107704:
version "1.2.0-dev-1646213107704"
resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.2.0-dev-1646213107704.tgz#1a5310484afb5c1d127ba5775e08e3c0502e1488"
integrity sha512-3+Ve8f5a+dvdcRafOzAuisXk/vwY9fBXgshof8xYFgtDso2INwMHTNIEgUQrjMd8yXt2+EO8l9wlBeAr02eQ0A==
medusa-interfaces@1.2.0-dev-1647336201011:
version "1.2.0-dev-1647336201011"
resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.2.0-dev-1647336201011.tgz#f6a25ef007c8ffe2828b4e2f64f23bd60929f4f5"
integrity sha512-8PvQpLfwB0YGTQU0qh/miDhJaOczoyCFs1bvlMbe3cOsn7ua7UKeUj+TLQQwh6ykbezjtggUgafEH+fKOmLUWg==
dependencies:
medusa-core-utils "1.1.31-dev-1646213107704"
medusa-core-utils "1.1.31-dev-1647336201011"
medusa-telemetry@0.0.11-dev-1646213107704:
version "0.0.11-dev-1646213107704"
resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.11-dev-1646213107704.tgz#7dc191fd263774a31795c2696ffe015a2b5f7443"
integrity sha512-BLKdzaU1sE9Eqnt3lmE6wkocOuC7wMT0l2OWc0aW6YTRRWDOffxOuANku1bZCIdVtbJ1q25UpeY2dwuD7lXhZw==
medusa-telemetry@0.0.11-dev-1647336201011:
version "0.0.11-dev-1647336201011"
resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.11-dev-1647336201011.tgz#d8a33d114a5bd4e6d2bdef112a07eed96a5b2f8d"
integrity sha512-am/sacr0XhCyc2OT6w8QCLuI7AXioJvaXxPzB4P1n5OiEGOn3IJIHZJY22MQzdY/dJZ7Rb4XuFlXdZInbc4woA==
dependencies:
axios "^0.21.1"
axios-retry "^3.1.9"
@@ -5170,13 +5170,13 @@ medusa-telemetry@0.0.11-dev-1646213107704:
remove-trailing-slash "^0.1.1"
uuid "^8.3.2"
medusa-test-utils@1.1.37-dev-1646213107704:
version "1.1.37-dev-1646213107704"
resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.37-dev-1646213107704.tgz#5fdb8298ac482f1d89475115d7727dd5227ac866"
integrity sha512-S42x4NaioWXPwQluO+iOYDwV0LUmr7q58XClFJA8j7Nw07IFKJU2PPHAAPrbpWQKpa2pdCBYdR0FUID4/VOfXw==
medusa-test-utils@1.1.37-dev-1647336201011:
version "1.1.37-dev-1647336201011"
resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.37-dev-1647336201011.tgz#6a1cb877abf79210db0065095b1baa0427eb93d1"
integrity sha512-K6J7FlUm3q3jL94Ye01gpk3rcan9JEH1SDRoUm1zqZ0XAX3tl49R7VQtHO7kWcO05o2JUJt8/+LvNv6suth4ow==
dependencies:
"@babel/plugin-transform-classes" "^7.9.5"
medusa-core-utils "1.1.31-dev-1646213107704"
medusa-core-utils "1.1.31-dev-1647336201011"
randomatic "^3.1.1"
merge-descriptors@1.0.1:
+21 -20
View File
@@ -1,33 +1,33 @@
import { Router } from "express"
import cors from "cors"
import { Router } from "express"
import middlewares from "../../middlewares"
import appRoutes from "./apps"
import authRoutes from "./auth"
import productRoutes from "./products"
import userRoutes, { unauthenticatedUserRoutes } from "./users"
import collectionRoutes from "./collections"
import customerGroupRoutes from "./customer-groups"
import customerRoutes from "./customers"
import discountRoutes from "./discounts"
import draftOrderRoutes from "./draft-orders"
import giftCardRoutes from "./gift-cards"
import inviteRoutes, { unauthenticatedInviteRoutes } from "./invites"
import noteRoutes from "./notes"
import notificationRoutes from "./notifications"
import orderRoutes from "./orders"
import priceListRoutes from "./price-lists"
import productTagRoutes from "./product-tags"
import productTypesRoutes from "./product-types"
import productRoutes from "./products"
import regionRoutes from "./regions"
import returnReasonRoutes from "./return-reasons"
import returnRoutes from "./returns"
import shippingOptionRoutes from "./shipping-options"
import shippingProfileRoutes from "./shipping-profiles"
import discountRoutes from "./discounts"
import giftCardRoutes from "./gift-cards"
import orderRoutes from "./orders"
import returnReasonRoutes from "./return-reasons"
import storeRoutes from "./store"
import uploadRoutes from "./uploads"
import customerRoutes from "./customers"
import appRoutes from "./apps"
import swapRoutes from "./swaps"
import returnRoutes from "./returns"
import variantRoutes from "./variants"
import draftOrderRoutes from "./draft-orders"
import collectionRoutes from "./collections"
import productTagRoutes from "./product-tags"
import notificationRoutes from "./notifications"
import noteRoutes from "./notes"
import taxRateRoutes from "./tax-rates"
import productTypesRoutes from "./product-types"
import customerGroupRoutes from "./customer-groups"
import uploadRoutes from "./uploads"
import userRoutes, { unauthenticatedUserRoutes } from "./users"
import variantRoutes from "./variants"
const route = Router()
@@ -86,6 +86,7 @@ export default (app, container, config) => {
inviteRoutes(route)
taxRateRoutes(route)
customerGroupRoutes(route)
priceListRoutes(route)
return app
}
@@ -0,0 +1,105 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { PriceListServiceMock } from "../../../../../services/__mocks__/price-list"
describe("POST /price-lists/:id/prices/batch", () => {
describe("successfully adds several new prices to a price list", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/price-lists/pl_1234/prices/batch`,
{
payload: {
prices: [
{
currency_code: "usd",
amount: 500,
min_quantity: 10,
max_quantity: 20,
variant_id: "variant_12",
},
{
currency_code: "usd",
amount: 430,
min_quantity: 21,
max_quantity: 40,
variant_id: "variant_12",
},
],
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls PriceListService addPrices", () => {
expect(PriceListServiceMock.addPrices).toHaveBeenCalledTimes(1)
expect(PriceListServiceMock.addPrices).toHaveBeenCalledWith(
"pl_1234",
[
{
currency_code: "usd",
amount: 500,
min_quantity: 10,
max_quantity: 20,
variant_id: "variant_12",
},
{
currency_code: "usd",
amount: 430,
min_quantity: 21,
max_quantity: 40,
variant_id: "variant_12",
},
],
undefined
)
})
})
describe("fails if no prices were provided", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/price-lists/pl_1234/prices/batch`,
{
payload: {},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns descriptive error that name is missing", () => {
expect(subject.body.type).toEqual("invalid_data")
expect(subject.body.message).toEqual("prices must be an array")
})
})
})
@@ -0,0 +1,119 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { PriceListServiceMock } from "../../../../../services/__mocks__/price-list"
describe("POST /price-lists", () => {
describe("successfully creates a price list", () => {
let subject
beforeAll(async () => {
subject = await request("POST", `/admin/price-lists`, {
payload: {
name: "My Price List",
description: "testing",
ends_at: "2022-03-14T08:28:38.551Z",
starts_at: "2022-03-14T08:28:38.551Z",
customer_groups: [
{
id: "gc_123",
},
],
type: "sale",
prices: [
{
currency_code: "usd",
amount: 500,
min_quantity: 10,
max_quantity: 20,
variant_id: "variant_12",
},
{
currency_code: "usd",
amount: 430,
min_quantity: 21,
max_quantity: 40,
variant_id: "variant_12",
},
],
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
afterAll(() => {
jest.clearAllMocks()
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls PriceListService addPrices", () => {
expect(PriceListServiceMock.create).toHaveBeenCalledTimes(1)
expect(PriceListServiceMock.create).toHaveBeenCalledWith({
name: "My Price List",
description: "testing",
ends_at: "2022-03-14T08:28:38.551Z",
starts_at: "2022-03-14T08:28:38.551Z",
customer_groups: [
{
id: "gc_123",
},
],
type: "sale",
prices: [
{
currency_code: "usd",
amount: 500,
min_quantity: 10,
max_quantity: 20,
variant_id: "variant_12",
},
{
currency_code: "usd",
amount: 430,
min_quantity: 21,
max_quantity: 40,
variant_id: "variant_12",
},
],
})
})
})
describe("fails if required fields are missing", () => {
let subject
beforeAll(async () => {
subject = await request("POST", `/admin/price-lists`, {
payload: {
description: "bad payload",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
afterAll(() => {
jest.clearAllMocks()
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns descriptive error that several fields are missing", () => {
expect(subject.body.type).toEqual("invalid_data")
expect(subject.body.message).toEqual(
"name must be a string, type must be a valid enum value, prices must be an array"
)
})
})
})
@@ -0,0 +1,38 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { PriceListServiceMock } from "../../../../../services/__mocks__/price-list"
describe("POST /price-lists/:id/prices/batch", () => {
describe("successfully adds several new prices to a price list", () => {
let subject
beforeAll(async () => {
subject = await request("DELETE", `/admin/price-lists/pl_1234`, {
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
afterAll(() => {
jest.clearAllMocks()
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls PriceListService addPrices", () => {
expect(PriceListServiceMock.delete).toHaveBeenCalledTimes(1)
expect(PriceListServiceMock.delete).toHaveBeenCalledWith("pl_1234")
expect(subject.body).toEqual({
id: "pl_1234",
object: "price-list",
deleted: true,
})
})
})
})
@@ -0,0 +1,76 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { PriceListServiceMock } from "../../../../../services/__mocks__/price-list"
describe("DELETE /price-lists/:id/prices/batch", () => {
describe("successfully adds several new prices to a price list", () => {
let subject
beforeAll(async () => {
subject = await request(
"DELETE",
`/admin/price-lists/pl_1234/prices/batch`,
{
payload: {
price_ids: ["price_1234", "price_1235", "price_1236", "price_1237"],
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls PriceListService addPrices", () => {
expect(PriceListServiceMock.deletePrices).toHaveBeenCalledTimes(1)
expect(PriceListServiceMock.deletePrices).toHaveBeenCalledWith(
"pl_1234",
["price_1234", "price_1235", "price_1236", "price_1237"]
)
})
})
describe("fails if no prices were provided", () => {
let subject
beforeAll(async () => {
subject = await request(
"DELETE",
`/admin/price-lists/pl_1234/prices/batch`,
{
payload: {},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns descriptive error that price_ids is missing", () => {
expect(subject.body.type).toEqual("invalid_data")
expect(subject.body.message).toEqual(
"each value in price_ids must be a string, price_ids should not be empty"
)
})
})
})
@@ -0,0 +1,36 @@
import { IdMap } from "medusa-test-utils"
import { defaultAdminPriceListFields, defaultAdminPriceListRelations } from ".."
import { request } from "../../../../../helpers/test-request"
import { PriceListServiceMock } from "../../../../../services/__mocks__/price-list"
describe("GET /price-lists/:id", () => {
describe("", () => {
let subject
beforeAll(async () => {
subject = await request("GET", `/admin/price-lists/pl_1234`, {
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
afterAll(() => {
jest.clearAllMocks()
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls PriceListService retrieve", () => {
expect(PriceListServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(PriceListServiceMock.retrieve).toHaveBeenCalledWith("pl_1234", {
relations: defaultAdminPriceListRelations,
select: defaultAdminPriceListFields,
})
})
})
})
@@ -0,0 +1,45 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { PriceListServiceMock } from "../../../../../services/__mocks__/price-list"
describe("GET /price-lists", () => {
describe("successfully lists price lists", () => {
let subject
beforeAll(async () => {
subject = await request("GET", `/admin/price-lists`, {
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
afterAll(() => {
jest.clearAllMocks()
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls PriceListService listAndCount", () => {
expect(PriceListServiceMock.listAndCount).toHaveBeenCalledTimes(1)
expect(PriceListServiceMock.listAndCount).toHaveBeenCalledWith(
{
created_at: undefined,
deleted_at: undefined,
description: undefined,
id: undefined,
name: undefined,
q: undefined,
status: undefined,
type: undefined,
updated_at: undefined,
},
{ order: { created_at: "DESC" }, relations: [], skip: 0, take: 10 }
)
})
})
})
@@ -0,0 +1,37 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { PriceListServiceMock } from "../../../../../services/__mocks__/price-list"
describe("POST /price-lists/:id", () => {
describe("successfully updates a price list", () => {
let subject
beforeAll(async () => {
subject = await request("POST", `/admin/price-lists/pl_1234`, {
payload: {
description: "new description",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
afterAll(() => {
jest.clearAllMocks()
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls PriceListService update", () => {
expect(PriceListServiceMock.update).toHaveBeenCalledTimes(1)
expect(PriceListServiceMock.update).toHaveBeenCalledWith("pl_1234", {
description: "new description",
})
})
})
})
@@ -0,0 +1,99 @@
import { Type } from "class-transformer"
import { IsArray, IsBoolean, IsOptional, ValidateNested } from "class-validator"
import { defaultAdminPriceListFields, defaultAdminPriceListRelations } from "."
import { PriceList } from "../../../.."
import PriceListService from "../../../../services/price-list"
import { AdminPriceListPricesUpdateReq } from "../../../../types/price-list"
import { validator } from "../../../../utils/validator"
/**
* @oas [post] /price-lists/{id}/prices/batch
* operationId: "PostPriceListsPriceListPricesBatch"
* summary: "Batch update prices for a Price List"
* description: "Batch update prices for a Price List"
* x-authenticated: true
* parameters:
* - (path) id=* {string} The id of the Price List to update prices for.
* requestBody:
* content:
* application/json:
* schema:
* properties:
* prices:
* description: The prices to update or add.
* type: array
* items:
* properties:
* id:
* description: The id of the price.
* type: string
* status:
* description: The status of the Price List.
* type: string
* enum:
* - active
* - draft
* region_id:
* description: The id of the Region for which the price is used.
* type: string
* currency_code:
* description: The 3 character ISO currency code for which the price will be used.
* type: string
* amount:
* description: The amount of the price.
* type: number
* min_quantity:
* description: The minimum quantity for which the price will be used.
* type: number
* max_quantity:
* description: The maximum quantity for which the price will be used.
* type: number
* override:
* description: "If true the prices will replace all existing prices associated with the Price List."
* type: boolean
* tags:
* - Price List
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* id:
* type: string
* description: The id of the deleted Price List.
* object:
* type: string
* description: The type of the object that was deleted.
* deleted:
* type: boolean
*/
export default async (req, res) => {
const { id } = req.params
const validated = await validator(AdminPostPriceListPricesPricesReq, req.body)
const priceListService: PriceListService =
req.scope.resolve("priceListService")
await priceListService.addPrices(id, validated.prices, validated.override)
const priceList = await priceListService.retrieve(id, {
select: defaultAdminPriceListFields as (keyof PriceList)[],
relations: defaultAdminPriceListRelations,
})
res.json({ price_list: priceList })
}
export class AdminPostPriceListPricesPricesReq {
@IsArray()
@Type(() => AdminPriceListPricesUpdateReq)
@ValidateNested({ each: true })
prices: AdminPriceListPricesUpdateReq[]
@IsOptional()
@IsBoolean()
override?: boolean
}
@@ -0,0 +1,134 @@
import { Type } from "class-transformer"
import {
IsArray,
IsEnum,
IsOptional,
IsString,
ValidateNested,
} from "class-validator"
import PriceListService from "../../../../services/price-list"
import {
AdminPriceListPricesCreateReq,
PriceListStatus,
PriceListType,
} from "../../../../types/price-list"
import { validator } from "../../../../utils/validator"
/**
* @oas [post] /price_lists
* operationId: "PostPriceListsPriceList"
* summary: "Creates a Price List"
* description: "Creates a Price List"
* x-authenticated: true
* requestBody:
* content:
* application/json:
* schema:
* properties:
* name:
* description: "The name of the Price List"
* type: string
* description:
* description: "A description of the Price List."
* type: string
* type:
* description: The type of the Price List.
* type: string
* enum:
* - sale
* - override
* status:
* description: The status of the Price List.
* type: string
* enum:
* - active
* - draft
* prices:
* description: The prices of the Price List.
* type: array
* items:
* properties:
* region_id:
* description: The id of the Region for which the price is used.
* type: string
* currency_code:
* description: The 3 character ISO currency code for which the price will be used.
* type: string
* amount:
* description: The amount to charge for the Product Variant.
* type: integer
* min_quantity:
* description: The minimum quantity for which the price will be used.
* type: integer
* max_quantity:
* description: The maximum quantity for which the price will be used.
* type: integer
* customer_groups:
* type: array
* description: A list of customer groups that the Price List applies to.
* items:
* required:
* - id
* properties:
* id:
* description: The id of a customer group
* type: string
* tags:
* - Price List
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* product:
* $ref: "#/components/schemas/price_list"
*/
export default async (req, res) => {
const validated = await validator(AdminPostPriceListsPriceListReq, req.body)
const priceListService: PriceListService =
req.scope.resolve("priceListService")
const priceList = await priceListService.create(validated)
res.json({ price_list: priceList })
}
class CustomerGroup {
@IsString()
id: string
}
export class AdminPostPriceListsPriceListReq {
@IsString()
name: string
@IsString()
description: string
@IsOptional()
starts_at?: Date
@IsOptional()
ends_at?: Date
@IsOptional()
@IsEnum(PriceListStatus)
status?: PriceListStatus
@IsEnum(PriceListType)
type: PriceListType
@IsArray()
@Type(() => AdminPriceListPricesCreateReq)
@ValidateNested({ each: true })
prices: AdminPriceListPricesCreateReq[]
@IsOptional()
@IsArray()
@Type(() => CustomerGroup)
@ValidateNested({ each: true })
customer_groups?: CustomerGroup[]
}
@@ -0,0 +1,41 @@
import PriceListService from "../../../../services/price-list"
/**
* @oas [delete] /price-lists/{id}
* operationId: "DeletePriceListsPriceList"
* summary: "Delete a Price List"
* description: "Deletes a Price List"
* x-authenticated: true
* parameters:
* - (path) id=* {string} The id of the Price List to delete.
* tags:
* - Price List
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* id:
* type: string
* description: The id of the deleted Price List.
* object:
* type: string
* description: The type of the object that was deleted.
* deleted:
* type: boolean
*/
export default async (req, res) => {
const { id } = req.params
const priceListService: PriceListService =
req.scope.resolve("priceListService")
await priceListService.delete(id)
res.json({
id,
object: "price-list",
deleted: true,
})
}
@@ -0,0 +1,63 @@
import { ArrayNotEmpty, IsString } from "class-validator"
import PriceListService from "../../../../services/price-list"
import { validator } from "../../../../utils/validator"
/**
* @oas [delete] /price-lists/{id}/prices/batch
* operationId: "DeletePriceListsPriceListPricesBatch"
* summary: "Batch delete prices that belongs to a Price List"
* description: "Batch delete prices that belongs to a Price List"
* x-authenticated: true
* parameters:
* - (path) id=* {string} The id of the Price List that the Money Amounts that will be deleted belongs to.
* requestBody:
* content:
* application/json:
* schema:
* properties:
* price_ids:
* description: The price id's of the Money Amounts to delete.
* type: array
* items:
* type: string
* tags:
* - Price List
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* ids:
* type: array
* items:
* type: string
* description: The id of the deleted Money Amount.
* object:
* type: string
* description: The type of the object that was deleted.
* deleted:
* type: boolean
*/
export default async (req, res) => {
const { id } = req.params
const validated = await validator(
AdminDeletePriceListPricesPricesReq,
req.body
)
const priceListService: PriceListService =
req.scope.resolve("priceListService")
await priceListService.deletePrices(id, validated.price_ids)
res.json({ ids: validated.price_ids, object: "money-amount", deleted: true })
}
export class AdminDeletePriceListPricesPricesReq {
@ArrayNotEmpty()
@IsString({ each: true })
price_ids: string[]
}
@@ -0,0 +1,37 @@
import { defaultAdminPriceListFields, defaultAdminPriceListRelations } from "."
import { PriceList } from "../../../.."
import PriceListService from "../../../../services/price-list"
/**
* @oas [get] /price-lists/{id}
* operationId: "GetPriceListsPriceList"
* summary: "Retrieve a Price List"
* description: "Retrieves a Price List."
* x-authenticated: true
* parameters:
* - (path) id=* {string} The id of the Price List.
* tags:
* - Price List
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* price_list:
* $ref: "#/components/schemas/price_list"
*/
export default async (req, res) => {
const { id } = req.params
const priceListService: PriceListService =
req.scope.resolve("priceListService")
const priceList = await priceListService.retrieve(id, {
select: defaultAdminPriceListFields as (keyof PriceList)[],
relations: defaultAdminPriceListRelations,
})
res.status(200).json({ price_list: priceList })
}
@@ -0,0 +1,67 @@
import { Router } from "express"
import "reflect-metadata"
import { PriceList } from "../../../.."
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
import middlewares from "../../../middlewares"
const route = Router()
export default (app) => {
app.use("/price-lists", route)
route.get("/:id", middlewares.wrap(require("./get-price-list").default))
route.get("/", middlewares.wrap(require("./list-price-lists").default))
route.post("/", middlewares.wrap(require("./create-price-list").default))
route.post("/:id", middlewares.wrap(require("./update-price-list").default))
route.delete("/:id", middlewares.wrap(require("./delete-price-list").default))
route.delete(
"/:id/prices/batch",
middlewares.wrap(require("./delete-prices-batch").default)
)
route.post(
"/:id/prices/batch",
middlewares.wrap(require("./add-prices-batch").default)
)
return app
}
export const defaultAdminPriceListFields = [
"id",
"name",
"description",
"type",
"status",
"starts_at",
"ends_at",
"created_at",
"updated_at",
"deleted_at",
]
export const defaultAdminPriceListRelations = ["prices", "customer_groups"]
export const allowedAdminPriceListFields = ["prices", "customer_groups"]
export type AdminPriceListRes = {
price_list: PriceList
}
export type AdminPriceListDeleteRes = DeleteResponse
export type AdminPriceListsListRes = PaginatedResponse & {
price_lists: PriceList[]
}
export * from "./add-prices-batch"
export * from "./create-price-list"
export * from "./delete-price-list"
export * from "./get-price-list"
export * from "./list-price-lists"
export * from "./update-price-list"
@@ -0,0 +1,106 @@
import { Type } from "class-transformer"
import { IsNumber, IsOptional, IsString } from "class-validator"
import omit from "lodash/omit"
import { PriceList } from "../../../.."
import PriceListService from "../../../../services/price-list"
import { FindConfig } from "../../../../types/common"
import { FilterablePriceListProps } from "../../../../types/price-list"
import { validator } from "../../../../utils/validator"
/**
* @oas [get] /price-lists
* operationId: "GetPriceLists"
* summary: "List Price Lists"
* description: "Retrieves a list of Price Lists."
* x-authenticated: true
* tags:
* - Price List
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* price_lists:
* type: array
* items:
* $ref: "#/components/schemas/price_list"
* count:
* description: The number of Price Lists.
* type: integer
* offset:
* description: The offset of the Price List query.
* type: integer
* limit:
* description: The limit of the Price List query.
* type: integer
*/
export default async (req, res) => {
const validated = await validator(
AdminGetPriceListPaginationParams,
req.query
)
const priceListService: PriceListService =
req.scope.resolve("priceListService")
let expandFields: string[] = []
if (validated.expand) {
expandFields = validated.expand.split(",")
}
const listConfig: FindConfig<PriceList> = {
relations: expandFields,
skip: validated.offset,
take: validated.limit,
order: { created_at: "DESC" } as { [k: string]: "DESC" },
}
if (typeof validated.order !== "undefined") {
if (validated.order.startsWith("-")) {
const [, field] = validated.order.split("-")
listConfig.order = { [field]: "DESC" }
} else {
listConfig.order = { [validated.order]: "ASC" }
}
}
const filterableFields = omit(validated, [
"limit",
"offset",
"expand",
"order",
])
const [price_lists, count] = await priceListService.listAndCount(
filterableFields,
listConfig
)
res.json({
price_lists,
count,
offset: validated.offset,
limit: validated.limit,
})
}
export class AdminGetPriceListPaginationParams extends FilterablePriceListProps {
@IsNumber()
@IsOptional()
@Type(() => Number)
offset?: number = 0
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 10
@IsString()
@IsOptional()
expand?: string
@IsString()
@IsOptional()
order?: string
}
@@ -0,0 +1,155 @@
import { Type } from "class-transformer"
import {
IsArray,
IsEnum,
IsOptional,
IsString,
ValidateNested,
} from "class-validator"
import { defaultAdminPriceListFields, defaultAdminPriceListRelations } from "."
import { PriceList } from "../../../.."
import PriceListService from "../../../../services/price-list"
import {
AdminPriceListPricesUpdateReq,
PriceListStatus,
PriceListType,
} from "../../../../types/price-list"
import { validator } from "../../../../utils/validator"
/**
* @oas [post] /price_lists/{id}
* operationId: "PostPriceListsPriceListPriceList"
* summary: "Update a Price List"
* description: "Updates a Price List"
* x-authenticated: true
* parameters:
* - (path) id=* {string} The id of the Price List.
* requestBody:
* content:
* application/json:
* schema:
* properties:
* name:
* description: "The name of the Price List"
* type: string
* description:
* description: "A description of the Price List."
* type: string
* type:
* description: The type of the Price List.
* type: string
* enum:
* - sale
* - override
* status:
* description: The status of the Price List.
* type: string
* enum:
* - active
* - draft
* prices:
* description: The prices of the Price List.
* type: array
* items:
* properties:
* id:
* description: The id of the price.
* type: string
* region_id:
* description: The id of the Region for which the price is used.
* type: string
* currency_code:
* description: The 3 character ISO currency code for which the price will be used.
* type: string
* amount:
* description: The amount to charge for the Product Variant.
* type: integer
* min_quantity:
* description: The minimum quantity for which the price will be used.
* type: integer
* max_quantity:
* description: The maximum quantity for which the price will be used.
* type: integer
* customer_groups:
* type: array
* description: A list of customer groups that the Price List applies to.
* items:
* required:
* - id
* properties:
* id:
* description: The id of a customer group
* type: string
* tags:
* - Price List
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* product:
* $ref: "#/components/schemas/price_list"
*/
export default async (req, res) => {
const { id } = req.params
const validated = await validator(
AdminPostPriceListsPriceListPriceListReq,
req.body
)
const priceListService: PriceListService =
req.scope.resolve("priceListService")
await priceListService.update(id, validated)
const priceList = await priceListService.retrieve(id, {
select: defaultAdminPriceListFields as (keyof PriceList)[],
relations: defaultAdminPriceListRelations,
})
res.json({ price_list: priceList })
}
class CustomerGroup {
@IsString()
id: string
}
export class AdminPostPriceListsPriceListPriceListReq {
@IsString()
@IsOptional()
name?: string
@IsString()
@IsOptional()
description?: string
@IsOptional()
starts_at?: Date
@IsOptional()
ends_at?: Date
@IsOptional()
@IsEnum(PriceListStatus)
status?: PriceListStatus
@IsOptional()
@IsEnum(PriceListType)
type?: PriceListType
@IsOptional()
@IsArray()
@Type(() => AdminPriceListPricesUpdateReq)
@ValidateNested({ each: true })
prices: AdminPriceListPricesUpdateReq[]
@IsOptional()
@IsArray()
@Type(() => CustomerGroup)
@ValidateNested({ each: true })
customer_groups: CustomerGroup[]
}
@@ -3,12 +3,10 @@ import {
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsNumber,
IsObject,
IsOptional,
IsString,
Validate,
ValidateNested,
} from "class-validator"
import { EntityManager } from "typeorm"
@@ -19,7 +17,7 @@ import {
ShippingProfileService,
} from "../../../../services"
import { ProductStatus } from "../../../../types/product"
import { XorConstraint } from "../../../../types/validators/xor"
import { ProductVariantPricesCreateReq } from "../../../../types/product-variant"
import { validator } from "../../../../utils/validator"
/**
@@ -308,21 +306,6 @@ class ProductOptionReq {
title: string
}
class ProductVariantPricesReq {
@Validate(XorConstraint, ["currency_code"])
region_id?: string
@Validate(XorConstraint, ["region_id"])
currency_code?: string
@IsInt()
amount: number
@IsOptional()
@IsInt()
sale_amount?: number
}
class ProductVariantReq {
@IsString()
title: string
@@ -393,8 +376,8 @@ class ProductVariantReq {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductVariantPricesReq)
prices: ProductVariantPricesReq[]
@Type(() => ProductVariantPricesCreateReq)
prices: ProductVariantPricesCreateReq[]
@IsOptional()
@Type(() => ProductVariantOptionReq)
@@ -2,17 +2,15 @@ import { Type } from "class-transformer"
import {
IsArray,
IsBoolean,
IsInt,
IsNumber,
IsObject,
IsOptional,
IsString,
Validate,
ValidateNested,
} from "class-validator"
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import { ProductService, ProductVariantService } from "../../../../services"
import { XorConstraint } from "../../../../types/validators/xor"
import { ProductVariantPricesCreateReq } from "../../../../types/product-variant"
import { validator } from "../../../../utils/validator"
/**
@@ -96,8 +94,11 @@ import { validator } from "../../../../utils/validator"
* amount:
* description: The amount to charge for the Product Variant.
* type: integer
* sale_amount:
* description: The sale amount to charge for the Product Variant.
* min_quantity:
* description: The minimum quantity for which the price will be used.
* type: integer
* max_quantity:
* description: The maximum quantity for which the price will be used.
* type: integer
* options:
* type: array
@@ -152,21 +153,6 @@ class ProductVariantOptionReq {
option_id: string
}
class ProductVariantPricesReq {
@Validate(XorConstraint, ["currency_code"])
region_id?: string
@Validate(XorConstraint, ["region_id"])
currency_code?: string
@IsInt()
amount: number
@IsOptional()
@IsInt()
sale_amount?: number
}
export class AdminPostProductsProductVariantsReq {
@IsString()
title: string
@@ -237,8 +223,8 @@ export class AdminPostProductsProductVariantsReq {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductVariantPricesReq)
prices: ProductVariantPricesReq[]
@Type(() => ProductVariantPricesCreateReq)
prices: ProductVariantPricesCreateReq[]
@IsOptional()
@Type(() => ProductVariantOptionReq)
@@ -1,8 +1,8 @@
import { Router } from "express"
import { Product, ProductTag, ProductType } from "../../../.."
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
import middlewares from "../../../middlewares"
import "reflect-metadata"
import { Product, ProductTag, ProductType } from "../../../.."
import { PaginatedResponse } from "../../../../types/common"
import middlewares from "../../../middlewares"
const route = Router()
@@ -9,7 +9,6 @@ import {
IsOptional,
IsString,
NotEquals,
Validate,
ValidateIf,
ValidateNested,
} from "class-validator"
@@ -19,7 +18,7 @@ import {
ProductStatus,
} from "."
import { ProductService } from "../../../../services"
import { XorConstraint } from "../../../../types/validators/xor"
import { ProductVariantPricesUpdateReq } from "../../../../types/product-variant"
import { validator } from "../../../../utils/validator"
/**
@@ -248,21 +247,6 @@ class ProductVariantOptionReq {
option_id: string
}
class ProductVariantPricesReq {
@Validate(XorConstraint, ["currency_code"])
region_id?: string
@Validate(XorConstraint, ["region_id"])
currency_code?: string
@IsInt()
amount: number
@IsOptional()
@IsInt()
sale_amount?: number
}
class ProductVariantReq {
@IsString()
@IsOptional()
@@ -339,8 +323,8 @@ class ProductVariantReq {
@IsArray()
@IsOptional()
@ValidateNested({ each: true })
@Type(() => ProductVariantPricesReq)
prices: ProductVariantPricesReq[]
@Type(() => ProductVariantPricesUpdateReq)
prices: ProductVariantPricesUpdateReq[]
@IsOptional()
@Type(() => ProductVariantOptionReq)
@@ -2,17 +2,15 @@ import { Type } from "class-transformer"
import {
IsArray,
IsBoolean,
IsInt,
IsNumber,
IsObject,
IsOptional,
IsString,
Validate,
ValidateNested,
} from "class-validator"
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import { ProductService, ProductVariantService } from "../../../../services"
import { XorConstraint } from "../../../../types/validators/xor"
import { ProductVariantPricesUpdateReq } from "../../../../types/product-variant"
import { validator } from "../../../../utils/validator"
/**
@@ -84,6 +82,9 @@ import { validator } from "../../../../utils/validator"
* type: array
* items:
* properties:
* id:
* description: The id of the price.
* type: string
* region_id:
* description: The id of the Region for which the price is used.
* type: string
@@ -93,8 +94,11 @@ import { validator } from "../../../../utils/validator"
* amount:
* description: The amount to charge for the Product Variant.
* type: integer
* sale_amount:
* description: The sale amount to charge for the Product Variant.
* min_quantity:
* description: The minimum quantity for which the price will be used.
* type: integer
* max_quantity:
* description: The maximum quantity for which the price will be used.
* type: integer
* options:
* type: array
@@ -152,21 +156,6 @@ class ProductVariantOptionReq {
option_id: string
}
class ProductVariantPricesReq {
@Validate(XorConstraint, ["currency_code"])
region_id?: string
@Validate(XorConstraint, ["region_id"])
currency_code?: string
@IsInt()
amount: number
@IsOptional()
@IsInt()
sale_amount?: number
}
export class AdminPostProductsProductVariantsVariantReq {
@IsString()
@IsOptional()
@@ -238,8 +227,8 @@ export class AdminPostProductsProductVariantsVariantReq {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductVariantPricesReq)
prices: ProductVariantPricesReq[]
@Type(() => ProductVariantPricesUpdateReq)
prices: ProductVariantPricesUpdateReq[]
@Type(() => ProductVariantOptionReq)
@ValidateNested({ each: true })
+1
View File
@@ -42,6 +42,7 @@ export * from "./models/order"
export * from "./models/payment"
export * from "./models/payment-provider"
export * from "./models/payment-session"
export * from "./models/price-list"
export * from "./models/product"
export * from "./models/product-collection"
export * from "./models/product-option"
@@ -0,0 +1,38 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class updateMoneyAmountAddPriceList1646915480108 implements MigrationInterface {
name = 'updateMoneyAmountAddPriceList1646915480108'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TYPE "price_list_type_enum" AS ENUM('sale', 'override')`);
await queryRunner.query(`CREATE TYPE "price_list_status_enum" AS ENUM('active', 'draft')`);
await queryRunner.query(`CREATE TABLE "price_list" ("id" character varying NOT NULL, "name" character varying NOT NULL, "description" character varying NOT NULL, "type" "price_list_type_enum" NOT NULL DEFAULT 'sale', "status" "price_list_status_enum" NOT NULL DEFAULT 'draft', "starts_at" TIMESTAMP WITH TIME ZONE, "ends_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_52ea7826468b1c889cb2c28df03" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "price_list_customer_groups" ("price_list_id" character varying NOT NULL, "customer_group_id" character varying NOT NULL, CONSTRAINT "PK_1afcbe15cc8782dc80c05707df9" PRIMARY KEY ("price_list_id", "customer_group_id"))`);
await queryRunner.query(`CREATE INDEX "IDX_52875734e9dd69064f0041f4d9" ON "price_list_customer_groups" ("price_list_id") `);
await queryRunner.query(`CREATE INDEX "IDX_c5516f550433c9b1c2630d787a" ON "price_list_customer_groups" ("customer_group_id") `);
await queryRunner.query(`ALTER TABLE "money_amount" DROP COLUMN "sale_amount"`);
await queryRunner.query(`ALTER TABLE "money_amount" ADD "min_quantity" integer`);
await queryRunner.query(`ALTER TABLE "money_amount" ADD "max_quantity" integer`);
await queryRunner.query(`ALTER TABLE "money_amount" ADD "price_list_id" character varying`);
await queryRunner.query(`ALTER TABLE "money_amount" ADD CONSTRAINT "FK_f249976b079375499662eb80c40" FOREIGN KEY ("price_list_id") REFERENCES "price_list"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "price_list_customer_groups" ADD CONSTRAINT "FK_52875734e9dd69064f0041f4d92" FOREIGN KEY ("price_list_id") REFERENCES "price_list"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
await queryRunner.query(`ALTER TABLE "price_list_customer_groups" ADD CONSTRAINT "FK_c5516f550433c9b1c2630d787a7" FOREIGN KEY ("customer_group_id") REFERENCES "customer_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "price_list_customer_groups" DROP CONSTRAINT "FK_c5516f550433c9b1c2630d787a7"`);
await queryRunner.query(`ALTER TABLE "price_list_customer_groups" DROP CONSTRAINT "FK_52875734e9dd69064f0041f4d92"`);
await queryRunner.query(`ALTER TABLE "money_amount" DROP CONSTRAINT "FK_f249976b079375499662eb80c40"`);
await queryRunner.query(`ALTER TABLE "money_amount" DROP COLUMN "price_list_id"`);
await queryRunner.query(`ALTER TABLE "money_amount" DROP COLUMN "max_quantity"`);
await queryRunner.query(`ALTER TABLE "money_amount" DROP COLUMN "min_quantity"`);
await queryRunner.query(`ALTER TABLE "money_amount" ADD "sale_amount" integer`);
await queryRunner.query(`DROP INDEX "IDX_c5516f550433c9b1c2630d787a"`);
await queryRunner.query(`DROP INDEX "IDX_52875734e9dd69064f0041f4d9"`);
await queryRunner.query(`DROP TABLE "price_list_customer_groups"`);
await queryRunner.query(`DROP TABLE "price_list"`);
await queryRunner.query(`DROP TYPE "price_list_status_enum"`);
await queryRunner.query(`DROP TYPE "price_list_type_enum"`);
}
}
+17 -17
View File
@@ -5,14 +5,14 @@ import {
DeleteDateColumn,
Entity,
Index,
JoinTable,
ManyToMany,
PrimaryColumn,
UpdateDateColumn,
UpdateDateColumn
} from "typeorm"
import { ulid } from "ulid"
import { Customer } from ".."
import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column"
import { Customer } from "./customer"
import { PriceList } from "./price-list"
@Entity()
export class CustomerGroup {
@@ -23,22 +23,22 @@ export class CustomerGroup {
@Column()
name: string
@ManyToMany(() => Customer, (customer) => customer.groups, {
onDelete: "CASCADE",
})
@JoinTable({
name: "customer_group_customers",
joinColumn: {
name: "customer_group_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "customer_id",
referencedColumnName: "id",
},
})
@ManyToMany(
() => Customer,
(customer) => customer.groups,
{
onDelete: "CASCADE",
}
)
customers: Customer[]
@ManyToMany(
() => PriceList,
(priceList) => priceList.customer_groups,
{ onDelete: "CASCADE" }
)
price_lists: PriceList[]
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
+30 -8
View File
@@ -8,11 +8,12 @@ import {
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
UpdateDateColumn
} from "typeorm"
import { ulid } from "ulid"
import { resolveDbType } from "../utils/db-aware-column"
import { Currency } from "./currency"
import { PriceList } from "./price-list"
import { ProductVariant } from "./product-variant"
import { Region } from "./region"
@@ -31,16 +32,34 @@ export class MoneyAmount {
@Column({ type: "int" })
amount: number
@Column({ type: "int", nullable: true, default: null })
sale_amount?: number
@Column({ type: "int", nullable: true })
min_quantity: number | null
@Column({ type: "int", nullable: true })
max_quantity: number | null
@Column({ nullable: true })
price_list_id: string | null
@ManyToOne(
() => PriceList,
(priceList) => priceList.prices,
{ cascade: true, onDelete: "CASCADE" }
)
@JoinColumn({ name: "price_list_id" })
price_list: PriceList | null
@Index()
@Column({ nullable: true })
variant_id: string
@ManyToOne(() => ProductVariant, (variant) => variant.prices, {
onDelete: "CASCADE",
})
@ManyToOne(
() => ProductVariant,
(variant) => variant.prices,
{
onDelete: "CASCADE",
}
)
@JoinColumn({ name: "variant_id" })
variant: ProductVariant
@@ -86,8 +105,11 @@ export class MoneyAmount {
* amount:
* description: "The amount in the smallest currecny unit (e.g. cents 100 cents to charge $1) that the Product Variant will cost."
* type: integer
* sale_amount:
* description: "An optional sale amount that the Product Variant will be available for when defined."
* min_quantity:
* description: "The minimum quantity that the Money Amount applies to. If this value is not set, the Money Amount applies to all quantities."
* type: integer
* max_quantity:
* description: "The maximum quantity that the Money Amount applies to. If this value is not set, the Money Amount applies to all quantities."
* type: integer
* variant_id:
* description: "The id of the Product Variant that the Money Amount belongs to."
+127
View File
@@ -0,0 +1,127 @@
import {
BeforeInsert,
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinTable,
ManyToMany,
OneToMany,
PrimaryColumn,
UpdateDateColumn
} from "typeorm"
import { ulid } from "ulid"
import { PriceListStatus, PriceListType } from "../types/price-list"
import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column"
import { CustomerGroup } from "./customer-group"
import { MoneyAmount } from "./money-amount"
@Entity()
export class PriceList {
@PrimaryColumn()
id: string
@Column()
name: string
@Column()
description: string
@DbAwareColumn({ type: "enum", enum: PriceListType, default: "sale" })
type: PriceListType
@DbAwareColumn({ type: "enum", enum: PriceListStatus, default: "draft" })
status: PriceListStatus
@Column({
type: resolveDbType("timestamptz"),
nullable: true,
})
starts_at: Date
@Column({ type: resolveDbType("timestamptz"), nullable: true })
ends_at: Date
@JoinTable({
name: "price_list_customer_groups",
joinColumn: {
name: "price_list_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "customer_group_id",
referencedColumnName: "id",
},
})
@ManyToMany(
() => CustomerGroup,
(cg) => cg.price_lists,
{ onDelete: "CASCADE" }
)
customer_groups: CustomerGroup[]
@OneToMany(
() => MoneyAmount,
(moneyAmount) => moneyAmount.price_list,
{ onDelete: "CASCADE" }
)
prices: MoneyAmount[]
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
@UpdateDateColumn({ type: resolveDbType("timestamptz") })
updated_at: Date
@DeleteDateColumn({ type: resolveDbType("timestamptz") })
deleted_at: Date
@BeforeInsert()
private beforeInsert(): undefined | void {
if (this.id) {
return
}
const id = ulid()
this.id = `pl_${id}`
}
}
/**
* @schema price_list
* title: "Price List"
* description: "Price Lists represents a set of prices that overrides the default price for one or more product variants."
* x-resourceId: price_list
* properties:
* id:
* description: "The id of the Price List. This value will be prefixed by `pl_`."
* type: string
* type:
* description: "The type of Price List. This can be one of either `sale` or `override`."
* type: string
* enum:
* - sale
* - override
* starts_at:
* description: "The date with timezone that the Price List starts being valid."
* type: date-time
* ends_at:
* description: "The date with timezone that the Price List stops being valid."
* type: date-time
* customer_groups:
* description: "The Customer Groups that the Price List applies to."
* type: array
* items:
* $ref: "#/components/schemas/customer_group"
* created_at:
* description: "The date with timezone at which the resource was created."
* type: string
* format: date-time
* updated_at:
* description: "The date with timezone at which the resource was last updated."
* type: string
* format: date-time
* deleted_at:
* description: "The date with timezone at which the resource was deleted."
* type: string
* format: date-time
*/
@@ -0,0 +1,31 @@
import { IdMap } from "medusa-test-utils"
export const moneyAmounts = {
amountOne: {
id: IdMap.getId("amountOne"),
currency_code: "USD",
amount: 1,
min_quantity: 1,
max_quantity: 10,
price_list_id: null,
}
}
export const MoneyAmountModelMock = {
create: jest.fn().mockReturnValue(Promise.resolve()),
findOne: jest.fn().mockImplementation(query => {
if (query._id === IdMap.getId("amountOne")) {
return Promise.resolve(moneyAmounts.amountOne)
}
return Promise.resolve(undefined)
}),
addToPriceList: jest.fn().mockImplementation((priceListId, prices, overrideExisting) => {
return Promise.resolve()
}),
deletePriceListPrices: jest.fn().mockImplementation((priceListId, moneyAmountIds) => {
return Promise.resolve()
}),
updatePriceListPrices: jest.fn().mockImplementation((priceListId, updates) => {
return Promise.resolve()
}),
}
@@ -0,0 +1,22 @@
export const PriceListModelMock = {
create: jest.fn().mockReturnValue(Promise.resolve()),
updateOne: jest.fn().mockImplementation((query, update) => {
return Promise.resolve()
}),
deleteOne: jest.fn().mockReturnValue(Promise.resolve()),
findOne: jest.fn().mockImplementation(query => {
if (query.email === "oliver@medusa.com") {
return Promise.resolve(customers.testCustomer)
}
if (query.phone === "12345678") {
return Promise.resolve(customers.customerWithPhone)
}
if (query._id === IdMap.getId("testCustomer")) {
return Promise.resolve(customers.testCustomer)
}
if (query._id === IdMap.getId("deleteId")) {
return Promise.resolve(customers.deleteCustomer)
}
return Promise.resolve(undefined)
}),
}
@@ -1,19 +1,20 @@
import partition from "lodash/partition"
import {
Brackets,
EntityRepository,
In,
IsNull,
Not,
Repository,
Repository
} from "typeorm"
import { MoneyAmount } from "../models/money-amount"
import { PriceListPriceCreateInput, PriceListPriceUpdateInput } from "../types/price-list"
type Price = Partial<
Pick<
MoneyAmount,
"currency_code" | "region_id" | "sale_amount" | "currency_code"
>
> & { amount: number }
Omit<MoneyAmount, "created_at" | "updated_at" | "deleted_at">
> & {
amount: number
}
@EntityRepository(MoneyAmount)
export class MoneyAmountRepository extends Repository<MoneyAmount> {
@@ -21,6 +22,7 @@ export class MoneyAmountRepository extends Repository<MoneyAmount> {
const pricesNotInPricesPayload = await this.createQueryBuilder()
.where({
variant_id: variantId,
price_list_id: IsNull(),
})
.andWhere(
new Brackets((qb) => {
@@ -33,12 +35,13 @@ export class MoneyAmountRepository extends Repository<MoneyAmount> {
return pricesNotInPricesPayload
}
public async upsertCurrencyPrice(variantId: string, price: Price) {
public async upsertVariantCurrencyPrice(variantId: string, price: Price) {
let moneyAmount = await this.findOne({
where: {
currency_code: price.currency_code,
variant_id: variantId,
region_id: IsNull(),
price_list_id: IsNull(),
},
})
@@ -50,9 +53,64 @@ export class MoneyAmountRepository extends Repository<MoneyAmount> {
})
} else {
moneyAmount.amount = price.amount
moneyAmount.sale_amount = price.sale_amount
}
return await this.save(moneyAmount)
}
public async addPriceListPrices(
priceListId: string,
prices: PriceListPriceCreateInput[],
overrideExisting: boolean = false
): Promise<MoneyAmount[]> {
const toInsert = prices.map((price) => (this.create({
...price,
price_list_id: priceListId,
})))
const insertResult = await this.createQueryBuilder()
.insert()
.orIgnore(true)
.into(MoneyAmount)
.values(toInsert)
.execute()
if (overrideExisting) {
await this.createQueryBuilder()
.delete()
.from(MoneyAmount)
.where({
price_list_id: priceListId,
id: Not(In(insertResult.identifiers.map((ma) => ma.id))),
})
.execute()
}
return await this.manager
.createQueryBuilder(MoneyAmount, "ma")
.select()
.where(insertResult.identifiers)
.getMany()
}
public async deletePriceListPrices(
priceListId: string,
moneyAmountIds: string[]
): Promise<void> {
await this.createQueryBuilder()
.delete()
.from(MoneyAmount)
.where({ price_list_id: priceListId, id: In(moneyAmountIds) })
.execute()
}
public async updatePriceListPrices(
priceListId: string,
updates: PriceListPriceUpdateInput[]
): Promise<MoneyAmount[]> {
const [existingPrices, newPrices] = partition(updates, (update) => update.id)
const newPriceEntities = newPrices.map((price) => (this.create({ ...price, price_list_id: priceListId })))
return await this.save([...existingPrices, ...newPriceEntities])
}
}
@@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm"
import { PriceList } from "../models/price-list"
@EntityRepository(PriceList)
export class PriceListRepository extends Repository<PriceList> {}
@@ -0,0 +1,43 @@
export const PriceListServiceMock = {
withTransaction: function() {
return this
},
create: jest.fn().mockImplementation((f) => {
return Promise.resolve(f)
}),
retrieve: jest.fn().mockImplementation((f) => {
return Promise.resolve(f)
}),
update: jest.fn().mockImplementation((id, update) => {
return Promise.resolve({ id, ...update })
}),
delete: jest.fn().mockImplementation((id) => {
return Promise.resolve(id)
}),
addPrices: jest.fn().mockImplementation((id, prices) => {
return Promise.resolve({ id, prices })
}),
deletePrices: jest.fn().mockImplementation((id, prices) => {
return Promise.resolve({ id, prices })
}),
listAndCount: jest.fn().mockImplementation((fields, config) => {
return Promise.resolve([[{ id: "pl_1" }, { id: "pl_2" }], 2])
}),
upsertCustomerGroups_: jest.fn().mockImplementation((id, groups) => {
return Promise.resolve()
}),
}
const mock = jest.fn().mockImplementation(() => {
return PriceListServiceMock
})
export default mock
@@ -98,7 +98,9 @@ const variantWithPrices = {
id: "price_1",
currency_code: "usd",
amount: 100,
sale_amount: null,
price_list_id: null,
min_quantity: 1,
max_quantity: 10,
variant_id: "variant_with_prices",
region_id: null,
created_at: "2021-03-16T21:24:13.657Z",
@@ -109,7 +111,9 @@ const variantWithPrices = {
id: "price_2",
currency_code: "dk",
amount: 100,
sale_amount: null,
price_list_id: null,
min_quantity: 1,
max_quantity: 10,
variant_id: "variant_with_prices",
region_id: null,
created_at: "2021-03-16T21:24:13.657Z",
@@ -285,8 +289,12 @@ export const ProductVariantServiceMock = {
}),
delete: jest.fn().mockReturnValue(Promise.resolve()),
update: jest.fn().mockReturnValue(Promise.resolve()),
setCurrencyPrice: jest.fn().mockReturnValue(Promise.resolve()),
setRegionPrice: jest.fn().mockReturnValue(Promise.resolve()),
updateVariantPrices: jest.fn().mockImplementation((variantId, prices) => {
return Promise.resolve({})
}),
deleteVariantPrices: jest.fn().mockImplementation((variantId, priceIds) => {
return Promise.resolve({})
}),
updateOptionValue: jest.fn().mockReturnValue(Promise.resolve()),
addOptionValue: jest.fn().mockImplementation((variantId, optionId, value) => {
return Promise.resolve({})
@@ -0,0 +1,121 @@
import { MedusaError } from "medusa-core-utils"
import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import PriceListService from "../price-list"
describe("PriceListService", () => {
const priceListRepository = MockRepository({
findOne: (q) => {
if (q === IdMap.getId("batman")) {
return Promise.resolve(undefined)
}
return Promise.resolve({ id: IdMap.getId("ironman") })
},
create: (data) => {
return Promise.resolve({ id: IdMap.getId("ironman"), ...data })
},
save: (data) => Promise.resolve(data),
})
const customerGroupService = {
retrieve: jest.fn((id) => {
if (id === IdMap.getId("group")) {
return Promise.resolve({ id: IdMap.getId("group") })
}
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`CustomerGroup with id ${id} was not found`
)
}),
}
const moneyAmountRepository = MockRepository()
moneyAmountRepository.addPriceListPrices = jest.fn(() => Promise.resolve())
moneyAmountRepository.removePriceListPrices = jest.fn(() => Promise.resolve())
moneyAmountRepository.updatePriceListPrices = jest.fn(() => Promise.resolve())
const defaultRelations = ["prices", "customer_groups"]
const priceListService = new PriceListService({
manager: MockManager,
customerGroupService,
priceListRepository,
moneyAmountRepository,
})
beforeEach(async () => {
jest.clearAllMocks()
})
describe("retrieve", () => {
it("successfully retrieves a price list", async () => {
const result = await priceListService.retrieve(IdMap.getId("ironman"))
expect(priceListRepository.findOne).toHaveBeenCalledTimes(1)
expect(priceListRepository.findOne).toHaveBeenCalledWith({
where: { id: IdMap.getId("ironman") },
})
expect(result.id).toEqual(IdMap.getId("ironman"))
})
it("fails on non-existing product variant id", async () => {
try {
await priceListService.retrieve(IdMap.getId("batman"))
} catch (error) {
expect(error.message).toBe(
`Price list with id: ${IdMap.getId("batman")} was not found`
)
}
})
})
describe("create", () => {
it("creates a new Price List", async () => {
const result = await priceListService.create({
name: "VIP winter sale",
description: "Winter sale for VIP customers. 25% off selected items.",
type: "sale",
status: "active",
starts_at: "2022-07-01T00:00:00.000Z",
ends_at: "2022-07-31T00:00:00.000Z",
customer_groups: [{ id: IdMap.getId("group") }],
prices: [
{
amount: 100,
currency_code: "usd",
min_quantity: 1,
max_quantity: 100,
},
],
})
expect(priceListRepository.create).toHaveBeenCalledTimes(1)
expect(priceListRepository.create).toHaveBeenCalledWith({
name: "VIP winter sale",
description: "Winter sale for VIP customers. 25% off selected items.",
type: "sale",
status: "active",
starts_at: "2022-07-01T00:00:00.000Z",
ends_at: "2022-07-31T00:00:00.000Z",
})
expect(customerGroupService.retrieve).toHaveBeenCalledTimes(1)
expect(customerGroupService.retrieve).toHaveBeenCalledWith(
IdMap.getId("group")
)
expect(moneyAmountRepository.addPriceListPrices).toHaveBeenCalledTimes(1)
expect(moneyAmountRepository.addPriceListPrices).toHaveBeenCalledWith(
IdMap.getId("ironman"),
[
{
amount: 100,
currency_code: "usd",
min_quantity: 1,
max_quantity: 100,
},
]
)
})
})
})
@@ -1,5 +1,4 @@
import { IdMap, MockRepository, MockManager } from "medusa-test-utils"
import idMap from "medusa-test-utils/dist/id-map"
import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import ProductVariantService from "../product-variant"
const eventBusService = {
@@ -376,7 +375,6 @@ describe("ProductVariantService", () => {
{
currency_code: "dkk",
amount: 1000,
sale_amount: 750,
},
],
})
@@ -388,7 +386,6 @@ describe("ProductVariantService", () => {
{
currency_code: "dkk",
amount: 1000,
sale_amount: 750,
},
]
)
@@ -435,6 +432,10 @@ describe("ProductVariantService", () => {
.fn()
.mockImplementation(() => Promise.resolve(oldPrices))
moneyAmountRepository.deleteVariantPrices = jest
.fn()
.mockImplementation((variant_id, price_ids) => Promise.resolve({}))
const productVariantRepository = MockRepository({
findOne: (query) => Promise.resolve({ id: IdMap.getId("ironman") }),
})
@@ -456,114 +457,9 @@ describe("ProductVariantService", () => {
.fn()
.mockReturnValue(() => Promise.resolve())
productVariantService.setCurrencyPrice = jest
.fn()
.mockReturnValue(() => Promise.resolve())
productVariantService.setRegionPrice = jest
.fn()
.mockReturnValue(() => Promise.resolve())
beforeEach(async () => {
jest.clearAllMocks()
})
it("successfully removes obsolete prices and calls setCurrencyPrice on new/existing prices", async () => {
await productVariantService.updateVariantPrices("ironman", [
{
currency_code: "usd",
amount: 4000,
},
])
expect(
moneyAmountRepository.findVariantPricesNotIn
).toHaveBeenCalledTimes(1)
expect(productVariantService.setCurrencyPrice).toHaveBeenCalledTimes(1)
expect(productVariantService.setCurrencyPrice).toHaveBeenCalledWith(
"ironman",
{
currency_code: "usd",
amount: 4000,
}
)
expect(moneyAmountRepository.remove).toHaveBeenCalledTimes(1)
expect(moneyAmountRepository.remove).toHaveBeenCalledWith(oldPrices)
})
it("successfully removes obsolete prices and calls setRegionPrice on new/existing prices", async () => {
await productVariantService.updateVariantPrices("ironman", [
{
region_id: "test-region",
amount: 4000,
sale_amount: 2000,
},
])
expect(
moneyAmountRepository.findVariantPricesNotIn
).toHaveBeenCalledTimes(1)
expect(productVariantService.setRegionPrice).toHaveBeenCalledTimes(1)
expect(productVariantService.setRegionPrice).toHaveBeenCalledWith(
"ironman",
{
region_id: "test-region",
amount: 4000,
sale_amount: 2000,
}
)
expect(moneyAmountRepository.remove).toHaveBeenCalledTimes(1)
expect(moneyAmountRepository.remove).toHaveBeenCalledWith(oldPrices)
})
})
describe("setCurrencyPrice", () => {
const productVariantRepository = MockRepository({
findOne: (query) => Promise.resolve({ id: IdMap.getId("ironman") }),
})
const moneyAmountRepository = MockRepository()
moneyAmountRepository.upsertCurrencyPrice = jest
.fn()
.mockImplementation((variantId, price) => {
return Promise.resolve({
id: IdMap.getId("test-amount"),
variant_id: IdMap.getId(variantId),
...price,
})
})
const productVariantService = new ProductVariantService({
manager: MockManager,
eventBusService,
moneyAmountRepository,
productVariantRepository,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("calls upsert price with given currency", async () => {
await productVariantService.setCurrencyPrice(IdMap.getId("ironman"), {
currency_code: "usd",
amount: 100,
})
expect(moneyAmountRepository.upsertCurrencyPrice).toHaveBeenCalledTimes(1)
expect(moneyAmountRepository.upsertCurrencyPrice).toHaveBeenCalledWith(
IdMap.getId("ironman"),
{
currency_code: "usd",
amount: 100,
}
)
})
})
describe("getRegionPrice", () => {
@@ -590,7 +486,6 @@ describe("ProductVariantService", () => {
currency_code: "dkk",
region_id: IdMap.getId("california"),
amount: 1000,
sale_amount: 750,
})
}
return Promise.resolve({
@@ -629,7 +524,8 @@ describe("ProductVariantService", () => {
IdMap.getId("california")
)
expect(result).toBe(750)
// TODO: Update once PriceStrategy is implemented
expect(result).toBe(1000)
})
it("fails if no price is found", async () => {
@@ -646,7 +542,7 @@ describe("ProductVariantService", () => {
})
})
describe("setRegionPrice", () => {
describe("updateVariantPrices", () => {
const moneyAmountRepository = MockRepository({
findOne: (query) => {
if (query.where.region_id === IdMap.getId("cali")) {
@@ -657,14 +553,47 @@ describe("ProductVariantService", () => {
variant_id: IdMap.getId("ironman"),
currency_code: "dkk",
amount: 750,
sale_amount: 500,
})
},
create: (p) => p,
remove: () => Promise.resolve(),
})
const oldPrices = [
{
currency_code: "dkk",
amount: 1000,
variant_id: "ironman",
region_id: null,
},
]
moneyAmountRepository.findVariantPricesNotIn = jest
.fn()
.mockImplementation(() => Promise.resolve(oldPrices))
moneyAmountRepository.upsertVariantCurrencyPrice = jest
.fn()
.mockImplementation(() => Promise.resolve())
const regionService = {
list: jest.fn().mockImplementation((config) => {
const idOrIds = config.id
if (Array.isArray(idOrIds)) {
return Promise.resolve(
idOrIds.map((id) => ({ id, currency_code: "usd" }))
)
} else {
return Promise.resolve([{ id: idOrIds, currency_code: "usd" }])
}
}),
}
const productVariantService = new ProductVariantService({
manager: MockManager,
eventBusService,
regionService,
moneyAmountRepository,
})
@@ -672,17 +601,43 @@ describe("ProductVariantService", () => {
jest.clearAllMocks()
})
it("successfully creates a price if none exist with given region id", async () => {
await productVariantService.setRegionPrice(IdMap.getId("ironman"), {
it("successfully removes obsolete prices and calls save on new/existing prices", async () => {
await productVariantService.updateVariantPrices("ironman", [
{
currency_code: "usd",
amount: 4000,
},
])
expect(
moneyAmountRepository.findVariantPricesNotIn
).toHaveBeenCalledTimes(1)
expect(
moneyAmountRepository.upsertVariantCurrencyPrice
).toHaveBeenCalledTimes(1)
expect(
moneyAmountRepository.upsertVariantCurrencyPrice
).toHaveBeenCalledWith("ironman", {
currency_code: "usd",
amount: 100,
region_id: IdMap.getId("cali"),
amount: 4000,
})
expect(moneyAmountRepository.remove).toHaveBeenCalledTimes(1)
expect(moneyAmountRepository.remove).toHaveBeenCalledWith(oldPrices)
})
it("successfully creates new a region price", async () => {
await productVariantService.updateVariantPrices(IdMap.getId("ironman"), [
{
amount: 100,
region_id: IdMap.getId("cali"),
},
])
expect(moneyAmountRepository.create).toHaveBeenCalledTimes(1)
expect(moneyAmountRepository.create).toHaveBeenCalledWith({
variant_id: IdMap.getId("ironman"),
currency_code: "usd",
region_id: IdMap.getId("cali"),
amount: 100,
})
@@ -690,22 +645,26 @@ describe("ProductVariantService", () => {
expect(moneyAmountRepository.save).toHaveBeenCalledTimes(1)
})
it("successfully updates a price", async () => {
await productVariantService.setRegionPrice(IdMap.getId("ironman"), {
currency_code: "dkk",
amount: 750,
sale_amount: 500,
})
it("successfully creates a currency price", async () => {
await productVariantService.updateVariantPrices(IdMap.getId("ironman"), [
{
id: IdMap.getId("dkk"),
currency_code: "dkk",
amount: 750,
},
])
expect(moneyAmountRepository.create).toHaveBeenCalledTimes(0)
expect(moneyAmountRepository.save).toHaveBeenCalledTimes(1)
expect(moneyAmountRepository.save).toHaveBeenCalledWith({
variant_id: IdMap.getId("ironman"),
expect(
moneyAmountRepository.upsertVariantCurrencyPrice
).toHaveBeenCalledTimes(1)
expect(
moneyAmountRepository.upsertVariantCurrencyPrice
).toHaveBeenCalledWith(IdMap.getId("ironman"), {
id: IdMap.getId("dkk"),
currency_code: "dkk",
amount: 750,
sale_amount: 500,
})
})
})
+2 -2
View File
@@ -1,6 +1,6 @@
import { BaseService } from "medusa-interfaces"
import { Validator, MedusaError } from "medusa-core-utils"
import { parse, toSeconds } from "iso8601-duration"
import { MedusaError, Validator } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { Brackets, ILike } from "typeorm"
import { formatException } from "../utils/exception-formatter"
+281
View File
@@ -0,0 +1,281 @@
import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { EntityManager } from "typeorm"
import { CustomerGroupService } from "."
import { CustomerGroup } from "../models/customer-group"
import { PriceList } from "../models/price-list"
import { MoneyAmountRepository } from "../repositories/money-amount"
import { PriceListRepository } from "../repositories/price-list"
import { FindConfig } from "../types/common"
import {
CreatePriceListInput,
FilterablePriceListProps,
PriceListPriceCreateInput,
UpdatePriceListInput,
} from "../types/price-list"
import { formatException } from "../utils/exception-formatter"
type PriceListConstructorProps = {
manager: EntityManager
customerGroupService: CustomerGroupService
priceListRepository: typeof PriceListRepository
moneyAmountRepository: typeof MoneyAmountRepository
}
/**
* Provides layer to manipulate product tags.
* @extends BaseService
*/
class PriceListService extends BaseService {
private manager_: EntityManager
private customerGroupService_: CustomerGroupService
private priceListRepo_: typeof PriceListRepository
private moneyAmountRepo_: typeof MoneyAmountRepository
constructor({
manager,
customerGroupService,
priceListRepository,
moneyAmountRepository,
}: PriceListConstructorProps) {
super()
this.manager_ = manager
this.customerGroupService_ = customerGroupService
this.priceListRepo_ = priceListRepository
this.moneyAmountRepo_ = moneyAmountRepository
}
withTransaction(transactionManager: EntityManager): PriceListService {
if (!transactionManager) {
return this
}
const cloned = new PriceListService({
manager: transactionManager,
customerGroupService: this.customerGroupService_,
priceListRepository: this.priceListRepo_,
moneyAmountRepository: this.moneyAmountRepo_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
* Retrieves a product tag by id.
* @param {string} priceListId - the id of the product tag to retrieve
* @param {Object} config - the config to retrieve the tag by
* @return {Promise<PriceList>} the collection.
*/
async retrieve(
priceListId: string,
config: FindConfig<PriceList> = {}
): Promise<PriceList> {
const priceListRepo = this.manager_.getCustomRepository(this.priceListRepo_)
const query = this.buildQuery_({ id: priceListId }, config)
const priceList = await priceListRepo.findOne(query)
if (!priceList) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Price list with id: ${priceListId} was not found`
)
}
return priceList
}
/**
* Creates a Price List
* @param {CreatePriceListInput} priceListObject - the Price List to create
* @return {Promise<PriceList>} created Price List
*/
async create(priceListObject: CreatePriceListInput): Promise<PriceList> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const priceListRepo = manager.getCustomRepository(this.priceListRepo_)
const moneyAmountRepo = manager.getCustomRepository(this.moneyAmountRepo_)
const { prices, customer_groups, ...rest } = priceListObject
try {
const entity = priceListRepo.create(rest)
const priceList = await priceListRepo.save(entity)
if (prices) {
await moneyAmountRepo.addPriceListPrices(priceList.id, prices)
}
if (customer_groups) {
await this.upsertCustomerGroups_(priceList.id, customer_groups)
}
const result = await this.retrieve(priceList.id, {
relations: ["prices", "customer_groups"],
})
return result
} catch (error) {
throw formatException(error)
}
})
}
/**
* Updates a Price List
* @param {string} id - the id of the Product List to update
* @param {UpdatePriceListInput} update - the update to apply
* @returns {Promise<PriceList>} updated Price List
*/
async update(id: string, update: UpdatePriceListInput): Promise<PriceList> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const priceListRepo = manager.getCustomRepository(this.priceListRepo_)
const moneyAmountRepo = manager.getCustomRepository(this.moneyAmountRepo_)
const priceList = await this.retrieve(id, { select: ["id"] })
const { prices, customer_groups, ...rest } = update
for (const [key, value] of Object.entries(rest)) {
priceList[key] = value
}
await priceListRepo.save(priceList)
if (prices) {
await moneyAmountRepo.updatePriceListPrices(id, prices)
}
if (customer_groups) {
await this.upsertCustomerGroups_(id, customer_groups)
}
const result = await this.retrieve(id, {
relations: ["prices", "customer_groups"],
})
return result
})
}
/**
* Adds prices to a price list in bulk, optionally replacing all existing prices
* @param id - id of the price list
* @param prices - prices to add
* @param replace - whether to replace existing prices
* @returns {Promise<PriceList>} updated Price List
*/
async addPrices(
id: string,
prices: PriceListPriceCreateInput[],
replace = false
): Promise<PriceList> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const moneyAmountRepo = manager.getCustomRepository(this.moneyAmountRepo_)
const priceList = await this.retrieve(id, { select: ["id"] })
await moneyAmountRepo.addPriceListPrices(priceList.id, prices, replace)
const result = await this.retrieve(priceList.id, {
relations: ["prices"],
})
return result
})
}
/**
* Removes prices from a price list and deletes the removed prices in bulk
* @param id - id of the price list
* @param priceIds - ids of the prices to delete
* @returns {Promise<void>} updated Price List
*/
async deletePrices(id: string, priceIds: string[]): Promise<void> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const moneyAmountRepo = manager.getCustomRepository(this.moneyAmountRepo_)
const priceList = await this.retrieve(id, { select: ["id"] })
await moneyAmountRepo.deletePriceListPrices(priceList.id, priceIds)
return Promise.resolve()
})
}
/**
* Deletes a Price List
* Will never fail due to delete being idempotent.
* @param id - id of the price list
* @returns {Promise<void>} empty promise
*/
async delete(id: string): Promise<void> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const priceListRepo = manager.getCustomRepository(this.priceListRepo_)
const priceList = await priceListRepo.findOne({ where: { id: id } })
if (!priceList) {
return Promise.resolve()
}
await priceListRepo.delete(priceList)
return Promise.resolve()
})
}
/**
* Lists Price Lists
* @param {Object} selector - the query object for find
* @param {Object} config - the config to be used for find
* @return {Promise<PriceList[]>} the result of the find operation
*/
async list(
selector: FilterablePriceListProps = {},
config: FindConfig<PriceList> = { skip: 0, take: 20 }
): Promise<PriceList[]> {
const priceListRepo = this.manager_.getCustomRepository(this.priceListRepo_)
const query = this.buildQuery_(selector, config)
return await priceListRepo.find(query)
}
/**
* Lists Price Lists and adds count
* @param {Object} selector - the query object for find
* @param {Object} config - the config to be used for find
* @return {Promise} the result of the find operation
*/
async listAndCount(
selector: FilterablePriceListProps = {},
config: FindConfig<PriceList> = { skip: 0, take: 20 }
): Promise<[PriceList[], number]> {
const priceListRepo = this.manager_.getCustomRepository(this.priceListRepo_)
const query = this.buildQuery_(selector, config)
return await priceListRepo.findAndCount(query)
}
async upsertCustomerGroups_(
priceListId: string,
customerGroups: { id: string }[]
): Promise<void> {
const priceListRepo = this.manager_.getCustomRepository(this.priceListRepo_)
const priceList = await this.retrieve(priceListId, { select: ["id"] })
const groups: CustomerGroup[] = []
for (const cg of customerGroups) {
const customerGroup = await this.customerGroupService_.retrieve(cg.id)
groups.push(customerGroup)
}
priceList.customer_groups = groups
await priceListRepo.save(priceList)
}
}
export default PriceListService
+38 -32
View File
@@ -1,7 +1,7 @@
import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { Brackets, EntityManager, ILike, In, SelectQueryBuilder } from "typeorm"
import { MoneyAmount } from "../models/money-amount"
import { Brackets, EntityManager, ILike, SelectQueryBuilder } from "typeorm"
import { MoneyAmount } from ".."
import { Product } from "../models/product"
import { ProductOptionValue } from "../models/product-option-value"
import { ProductVariant } from "../models/product-variant"
@@ -230,7 +230,6 @@ class ProductVariantService extends BaseService {
await this.setRegionPrice(result.id, {
amount: price.amount,
region_id: price.region_id,
sale_amount: price.sale_amount,
})
} else {
await this.setCurrencyPrice(result.id, price)
@@ -317,6 +316,13 @@ class ProductVariantService extends BaseService {
})
}
/**
* Updates a variant's prices.
* Deletes any prices that are not in the update object, and is not associated with a price list.
* @param variantId - the id of variant variant
* @param prices - the update prices
* @returns {Promise<void>} empty promise
*/
async updateVariantPrices(
variantId: string,
prices: ProductVariantPrice[]
@@ -337,7 +343,6 @@ class ProductVariantService extends BaseService {
await this.setRegionPrice(variantId, {
region_id: price.region_id,
amount: price.amount,
sale_amount: price.sale_amount || undefined,
})
} else {
await this.setCurrencyPrice(variantId, price)
@@ -348,25 +353,6 @@ class ProductVariantService extends BaseService {
})
}
/**
* Sets the default price for the given currency.
* @param {string} variantId - the id of the variant to set prices for
* @param {ProductVariantPrice} price - the price for the variant
* @return {Promise} the result of the update operation
*/
async setCurrencyPrice(
variantId: string,
price: ProductVariantPrice
): Promise<MoneyAmount> {
return this.atomicPhase_(async (manager: EntityManager) => {
const moneyAmountRepo = manager.getCustomRepository(
this.moneyAmountRepository_
)
return await moneyAmountRepo.upsertCurrencyPrice(variantId, price)
})
}
/**
* Gets the price specific to a region. If no region specific money amount
* exists the function will try to use a currency price. If no default
@@ -406,17 +392,12 @@ class ProductVariantService extends BaseService {
)
}
// Always return sale price, if present
if (moneyAmount.sale_amount) {
return moneyAmount.sale_amount
}
return moneyAmount.amount
})
}
/**
* Sets the price of a specific region
* Sets the default price of a specific region
* @param {string} variantId - the id of the variant to update
* @param {string} price - the price for the variant.
* @return {Promise} the result of the update operation
@@ -434,6 +415,7 @@ class ProductVariantService extends BaseService {
where: {
variant_id: variantId,
region_id: price.region_id,
price_list_id: null,
},
})
@@ -444,7 +426,6 @@ class ProductVariantService extends BaseService {
})
} else {
moneyAmount.amount = price.amount
moneyAmount.sale_amount = price.sale_amount
}
const result = await moneyAmountRepo.save(moneyAmount)
@@ -452,6 +433,25 @@ class ProductVariantService extends BaseService {
})
}
/**
* Sets the default price for the given currency.
* @param {string} variantId - the id of the variant to set prices for
* @param {ProductVariantPrice} price - the price for the variant
* @return {Promise} the result of the update operation
*/
async setCurrencyPrice(
variantId: string,
price: ProductVariantPrice
): Promise<MoneyAmount> {
return this.atomicPhase_(async (manager: EntityManager) => {
const moneyAmountRepo = manager.getCustomRepository(
this.moneyAmountRepository_
)
return await moneyAmountRepo.upsertVariantCurrencyPrice(variantId, price)
})
}
/**
* Updates variant's option value.
* Option value must be of type string or number.
@@ -636,7 +636,10 @@ class ProductVariantService extends BaseService {
this.productVariantRepository_
)
const variant = await variantRepo.findOne({ where: { id: variantId } })
const variant = await variantRepo.findOne({
where: { id: variantId },
relations: ["prices"],
})
if (!variant) {
return Promise.resolve()
@@ -662,7 +665,10 @@ class ProductVariantService extends BaseService {
* @param {Object} metadata - the metadata to set
* @return {Object} updated metadata object
*/
setMetadata_(variant: ProductVariant, metadata: object): Record<string, any> {
setMetadata_(
variant: ProductVariant,
metadata: object
): Record<string, unknown> {
const existing = variant.metadata || {}
const newData = {}
for (const [key, value] of Object.entries(metadata)) {
+151
View File
@@ -0,0 +1,151 @@
import { Type } from "class-transformer"
import {
IsEnum,
IsInt,
IsOptional,
IsString,
Validate,
ValidateIf,
ValidateNested,
} from "class-validator"
import { PriceList } from "../models/price-list"
import { DateComparisonOperator } from "./common"
import { XorConstraint } from "./validators/xor"
export enum PriceListType {
SALE = "sale",
OVERRIDE = "override",
}
export enum PriceListStatus {
ACTIVE = "active",
DRAFT = "draft",
}
export class FilterablePriceListProps {
@IsString()
@IsOptional()
id?: string
@IsString()
@IsOptional()
q?: string
@IsOptional()
@IsEnum(PriceListStatus, { each: true })
status?: PriceListStatus[]
@IsString()
@IsOptional()
name?: string
@IsString()
@IsOptional()
description?: string
@IsOptional()
@IsEnum(PriceListType, { each: true })
type?: PriceListType[]
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
created_at?: DateComparisonOperator
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
updated_at?: DateComparisonOperator
@ValidateNested()
@IsOptional()
@Type(() => DateComparisonOperator)
deleted_at?: DateComparisonOperator
}
export class AdminPriceListPricesUpdateReq {
@IsString()
@IsOptional()
id?: string
@ValidateIf((o) => !o.id)
@Validate(XorConstraint, ["currency_code"])
region_id?: string
@ValidateIf((o) => !o.id)
@Validate(XorConstraint, ["region_id"])
currency_code?: string
@IsString()
variant_id: string
@IsInt()
amount: number
@IsOptional()
@IsInt()
min_quantity?: number
@IsOptional()
@IsInt()
max_quantity?: number
}
export class AdminPriceListPricesCreateReq {
@Validate(XorConstraint, ["currency_code"])
region_id?: string
@Validate(XorConstraint, ["region_id"])
currency_code?: string
@IsInt()
amount: number
@IsString()
variant_id: string
@IsOptional()
@IsInt()
min_quantity?: number
@IsOptional()
@IsInt()
max_quantity?: number
}
export type CreatePriceListInput = {
name: string
description: string
type: PriceListType
status?: PriceListStatus
prices: AdminPriceListPricesCreateReq[]
customer_groups?: { id: string }[]
}
export type UpdatePriceListInput = Partial<
Pick<
PriceList,
"name" | "description" | "starts_at" | "ends_at" | "status" | "type"
>
> & {
prices?: AdminPriceListPricesUpdateReq[]
customer_groups?: { id: string }[]
}
export type PriceListPriceUpdateInput = {
id?: string
variant_id?: string
region_id?: string
currency_code?: string
amount?: number
min_quantity?: number
max_quantity?: number
}
export type PriceListPriceCreateInput = {
region_id?: string
currency_code?: string
amount: number
min_quantity?: number
max_quantity?: number
}
+58 -2
View File
@@ -1,16 +1,28 @@
import { IsBoolean, IsNumber, IsString, ValidateNested } from "class-validator"
import {
IsBoolean,
IsInt,
IsNumber,
IsOptional,
IsString,
Validate,
ValidateIf,
ValidateNested,
} from "class-validator"
import { IsType } from "../utils/validators/is-type"
import {
DateComparisonOperator,
NumericalComparisonOperator,
StringComparisonOperator,
} from "./common"
import { XorConstraint } from "./validators/xor"
export type ProductVariantPrice = {
id?: string
currency_code?: string
region_id?: string
amount: number
sale_amount?: number | undefined
min_quantity?: number
max_quantity?: number
}
export type ProductVariantOption = {
@@ -130,3 +142,47 @@ export class FilterableProductVariantProps {
@IsType([DateComparisonOperator])
updated_at?: DateComparisonOperator
}
export class ProductVariantPricesUpdateReq {
@IsString()
@IsOptional()
id?: string
@ValidateIf((o) => !o.id)
@Validate(XorConstraint, ["currency_code"])
region_id?: string
@ValidateIf((o) => !o.id)
@Validate(XorConstraint, ["region_id"])
currency_code?: string
@IsInt()
amount: number
@IsOptional()
@IsInt()
min_quantity?: number
@IsOptional()
@IsInt()
max_quantity?: number
}
export class ProductVariantPricesCreateReq {
@Validate(XorConstraint, ["currency_code"])
region_id?: string
@Validate(XorConstraint, ["region_id"])
currency_code?: string
@IsInt()
amount: number
@IsOptional()
@IsInt()
min_quantity?: number
@IsOptional()
@IsInt()
max_quantity?: number
}