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 Philip Korsholm Sebastian Rindom
parent 23f8399c16
commit 5300926db8
52 changed files with 4234 additions and 505 deletions
+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
}