feat(medusa): Add endpoints specific to DiscountConditions (#1355)

This commit is contained in:
Oliver Windall Juhl
2022-05-13 12:42:23 +02:00
committed by GitHub
parent a69b52e031
commit 9ca45ea492
20 changed files with 1433 additions and 159 deletions
@@ -14,3 +14,128 @@ Object {
"type": "duplicate_error",
}
`;
exports[`/admin/discounts POST /admin/discounts/:id/conditions fails if more than one condition type is provided: Only one of products, customer_groups is allowed, Only one of customer_groups, products is allowed 1`] = `"Request failed with status code 400"`;
exports[`/admin/discounts POST /admin/discounts/:id/conditions should create a condition 1`] = `
Object {
"code": "TEST",
"created_at": Any<String>,
"deleted_at": null,
"ends_at": null,
"id": "test-discount",
"is_disabled": false,
"is_dynamic": false,
"metadata": null,
"parent_discount": null,
"parent_discount_id": null,
"regions": Array [],
"rule": Object {
"allocation": "total",
"conditions": Array [
Object {
"created_at": Any<String>,
"deleted_at": null,
"discount_rule_id": Any<String>,
"id": Any<String>,
"metadata": null,
"operator": "in",
"type": "products",
"updated_at": Any<String>,
},
],
"created_at": Any<String>,
"deleted_at": null,
"description": null,
"id": Any<String>,
"metadata": null,
"type": "percentage",
"updated_at": Any<String>,
"value": 10,
},
"rule_id": Any<String>,
"starts_at": Any<String>,
"updated_at": Any<String>,
"usage_count": 0,
"usage_limit": null,
"valid_duration": null,
}
`;
exports[`/admin/discounts POST /admin/discounts/:id/conditions throws if discount does not exist: Discount with id does-not-exist was not found 1`] = `"Request failed with status code 404"`;
exports[`/admin/discounts POST /admin/discounts/:id/conditions/:condition_id should update a condition 1`] = `
Object {
"code": "TEST",
"created_at": Any<String>,
"deleted_at": null,
"ends_at": null,
"id": "test-discount",
"is_disabled": false,
"is_dynamic": false,
"metadata": null,
"parent_discount_id": null,
"rule": Object {
"allocation": "total",
"conditions": Array [
Object {
"created_at": Any<String>,
"deleted_at": null,
"discount_rule_id": Any<String>,
"id": Any<String>,
"metadata": null,
"operator": "in",
"products": Array [
Object {
"collection_id": null,
"created_at": Any<String>,
"deleted_at": null,
"description": null,
"discountable": true,
"external_id": null,
"handle": null,
"height": null,
"hs_code": null,
"id": "test-product",
"is_giftcard": false,
"length": null,
"material": null,
"metadata": null,
"mid_code": null,
"origin_country": null,
"profile_id": Any<String>,
"status": "draft",
"subtitle": null,
"thumbnail": null,
"title": "Practical Frozen Fish",
"type_id": Any<String>,
"updated_at": Any<String>,
"weight": null,
"width": null,
},
],
"type": "products",
"updated_at": Any<String>,
},
],
"created_at": Any<String>,
"deleted_at": null,
"description": null,
"id": Any<String>,
"metadata": null,
"type": "percentage",
"updated_at": Any<String>,
"value": 10,
},
"rule_id": Any<String>,
"starts_at": Any<String>,
"updated_at": Any<String>,
"usage_count": 0,
"usage_limit": null,
"valid_duration": null,
}
`;
exports[`/admin/discounts POST /admin/discounts/:id/conditions/:condition_id throws if condition does not exist: DiscountCondition with id does-not-exist was not found for Discount test-discount 1`] = `"Request failed with status code 404"`;
exports[`/admin/discounts POST /admin/discounts/:id/conditions/:condition_id throws if discount does not exist: Discount with id does-not-exist was not found 1`] = `"Request failed with status code 404"`;
+505 -39
View File
@@ -1,6 +1,5 @@
const path = require("path")
const {
Region,
DiscountRule,
Discount,
Customer,
@@ -12,7 +11,6 @@ const { useApi } = require("../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db")
const adminSeeder = require("../../helpers/admin-seeder")
const discountSeeder = require("../../helpers/discount-seeder")
const { exportAllDeclaration } = require("@babel/types")
const { simpleProductFactory } = require("../../factories")
const {
simpleDiscountFactory,
@@ -433,7 +431,6 @@ describe("/admin/discounts", () => {
await adminSeeder(dbConnection)
await discountSeeder(dbConnection)
} catch (err) {
console.log(err)
throw err
}
})
@@ -1468,41 +1465,37 @@ describe("/admin/discounts", () => {
describe("POST /admin/discounts/:discount_id/dynamic-codes", () => {
beforeEach(async () => {
const manager = dbConnection.manager
try {
await adminSeeder(dbConnection)
await manager.insert(DiscountRule, {
id: "test-discount-rule",
description: "Dynamic rule",
type: "percentage",
value: 10,
allocation: "total",
})
await manager.insert(Discount, {
id: "test-discount",
code: "DYNAMIC",
is_dynamic: true,
is_disabled: false,
rule_id: "test-discount-rule",
valid_duration: "P2Y",
})
await manager.insert(DiscountRule, {
id: "test-discount-rule1",
description: "Dynamic rule",
type: "percentage",
value: 10,
allocation: "total",
})
await manager.insert(Discount, {
id: "test-discount1",
code: "DYNAMICCode",
is_dynamic: true,
is_disabled: false,
rule_id: "test-discount-rule1",
})
} catch (err) {
console.log(err)
throw err
}
await adminSeeder(dbConnection)
await manager.insert(DiscountRule, {
id: "test-discount-rule",
description: "Dynamic rule",
type: "percentage",
value: 10,
allocation: "total",
})
await manager.insert(Discount, {
id: "test-discount",
code: "DYNAMIC",
is_dynamic: true,
is_disabled: false,
rule_id: "test-discount-rule",
valid_duration: "P2Y",
})
await manager.insert(DiscountRule, {
id: "test-discount-rule1",
description: "Dynamic rule",
type: "percentage",
value: 10,
allocation: "total",
})
await manager.insert(Discount, {
id: "test-discount1",
code: "DYNAMICCode",
is_dynamic: true,
is_disabled: false,
rule_id: "test-discount-rule1",
})
})
afterEach(async () => {
@@ -1554,7 +1547,7 @@ describe("/admin/discounts", () => {
}
)
.catch((err) => {
// console.log(err)
console.log(err)
})
expect(response.status).toEqual(200)
@@ -1566,4 +1559,477 @@ describe("/admin/discounts", () => {
)
})
})
describe("DELETE /admin/discounts/:id/conditions/:condition_id", () => {
beforeEach(async () => {
const manager = dbConnection.manager
await adminSeeder(dbConnection)
await manager.insert(DiscountRule, {
id: "test-discount-rule-fixed",
description: "Test discount rule",
type: "fixed",
value: 10,
allocation: "total",
})
const prod = await simpleProductFactory(dbConnection, { type: "pants" })
await simpleDiscountFactory(dbConnection, {
id: "test-discount",
code: "TEST",
rule: {
type: "percentage",
value: "10",
allocation: "total",
conditions: [
{
type: "products",
operator: "in",
products: [prod.id],
},
],
},
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should delete condition", async () => {
const api = useApi()
const group = await dbConnection.manager.insert(CustomerGroup, {
id: "customer-group-1",
name: "vip-customers",
})
await dbConnection.manager.insert(Customer, {
id: "cus_1234",
email: "oli@email.com",
groups: [group],
})
await simpleDiscountFactory(dbConnection, {
id: "test-discount",
code: "TEST",
rule: {
type: "percentage",
value: "10",
allocation: "total",
conditions: [
{
id: "test-condition",
type: "customer_groups",
operator: "in",
customer_groups: ["customer-group-1"],
},
],
},
})
const response = await api
.delete("/admin/discounts/test-discount/conditions/test-condition", {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.log(err)
})
const disc = response.data
expect(response.status).toEqual(200)
expect(disc).toEqual(
expect.objectContaining({
id: "test-condition",
deleted: true,
object: "discount-condition",
})
)
})
it("should not fail if condition does not exist", async () => {
const api = useApi()
const response = await api
.delete("/admin/discounts/test-discount/conditions/test-condition", {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.log(err)
})
const disc = response.data
expect(response.status).toEqual(200)
expect(disc).toEqual(
expect.objectContaining({
id: "test-condition",
deleted: true,
object: "discount-condition",
})
)
})
it("should fail if discount does not exist", async () => {
const api = useApi()
try {
await api.delete(
"/admin/discounts/not-exist/conditions/test-condition",
{
headers: {
Authorization: "Bearer test_token",
},
}
)
} catch (error) {
expect(error.message).toMatchSnapshot(
"Discount with id not-exist was not found"
)
}
})
})
describe("POST /admin/discounts/:id/conditions", () => {
beforeEach(async () => {
const manager = dbConnection.manager
await adminSeeder(dbConnection)
await manager.insert(DiscountRule, {
id: "test-discount-rule-fixed",
description: "Test discount rule",
type: "fixed",
value: 10,
allocation: "total",
})
await simpleDiscountFactory(dbConnection, {
id: "test-discount",
code: "TEST",
rule: {
type: "percentage",
value: "10",
allocation: "total",
},
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should create a condition", async () => {
const api = useApi()
const prod = await simpleProductFactory(dbConnection, { type: "pants" })
const response = await api
.post(
"/admin/discounts/test-discount/conditions",
{
operator: "in",
products: [prod.id],
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
const disc = response.data.discount
expect(response.status).toEqual(200)
expect(disc).toMatchSnapshot({
id: "test-discount",
code: "TEST",
created_at: expect.any(String),
updated_at: expect.any(String),
rule_id: expect.any(String),
starts_at: expect.any(String),
rule: {
id: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
conditions: [
{
id: expect.any(String),
discount_rule_id: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
},
],
},
})
})
it("fails if more than one condition type is provided", async () => {
const api = useApi()
const prod = await simpleProductFactory(dbConnection, { type: "pants" })
const group = await dbConnection.manager.insert(CustomerGroup, {
id: "customer-group-1",
name: "vip-customers",
})
await dbConnection.manager.insert(Customer, {
id: "cus_1234",
email: "oli@email.com",
groups: [group],
})
try {
await api.post(
"/admin/discounts/test-discount/conditions",
{
operator: "in",
products: [prod.id],
customer_groups: ["customer-group-1"],
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
} catch (error) {
expect(error.message).toMatchSnapshot(
"Only one of products, customer_groups is allowed, Only one of customer_groups, products is allowed"
)
}
})
it("throws if discount does not exist", async () => {
expect.assertions(1)
const api = useApi()
const prod2 = await simpleProductFactory(dbConnection, { type: "pants" })
try {
await api.post(
"/admin/discounts/does-not-exist/conditions/test-condition",
{
products: [prod2.id],
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
} catch (error) {
expect(error.message).toMatchSnapshot(
"Discount with id does-not-exist was not found"
)
}
})
})
describe("POST /admin/discounts/:id/conditions/:condition_id", () => {
beforeEach(async () => {
const manager = dbConnection.manager
await adminSeeder(dbConnection)
await manager.insert(DiscountRule, {
id: "test-discount-rule-fixed",
description: "Test discount rule",
type: "fixed",
value: 10,
allocation: "total",
})
const prod = await simpleProductFactory(dbConnection, { type: "pants" })
await simpleDiscountFactory(dbConnection, {
id: "test-discount",
code: "TEST",
rule: {
type: "percentage",
value: "10",
allocation: "total",
conditions: [
{
id: "test-condition",
type: "products",
operator: "in",
products: [prod.id],
},
],
},
})
await simpleDiscountFactory(dbConnection, {
id: "test-discount-2",
code: "TEST2",
rule: {
type: "percentage",
value: "10",
allocation: "total",
},
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should update a condition", async () => {
const api = useApi()
const prod2 = await simpleProductFactory(dbConnection, {
id: "test-product",
type: "pants",
})
const discount = await api
.get("/admin/discounts/test-discount", {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.log(err)
})
const cond = discount.data.discount.rule.conditions[0]
const response = await api
.post(
`/admin/discounts/test-discount/conditions/${cond.id}?expand=rule,rule.conditions,rule.conditions.products`,
{
products: [prod2.id],
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
const disc = response.data.discount
expect(response.status).toEqual(200)
expect(disc).toMatchSnapshot({
id: "test-discount",
code: "TEST",
created_at: expect.any(String),
updated_at: expect.any(String),
rule_id: expect.any(String),
starts_at: expect.any(String),
rule: {
id: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
conditions: [
{
id: expect.any(String),
discount_rule_id: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
products: [
{
created_at: expect.any(String),
updated_at: expect.any(String),
profile_id: expect.any(String),
type_id: expect.any(String),
id: "test-product",
},
],
},
],
},
})
})
it("throws if condition does not exist", async () => {
const api = useApi()
const prod2 = await simpleProductFactory(dbConnection, { type: "pants" })
try {
await api.post(
"/admin/discounts/test-discount/conditions/does-not-exist",
{
products: [prod2.id],
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
} catch (error) {
expect(error.message).toMatchSnapshot(
"DiscountCondition with id does-not-exist was not found for Discount test-discount"
)
}
})
it("throws if discount does not exist", async () => {
expect.assertions(1)
const api = useApi()
const prod2 = await simpleProductFactory(dbConnection, { type: "pants" })
try {
await api.post(
"/admin/discounts/does-not-exist/conditions/test-condition",
{
products: [prod2.id],
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
} catch (error) {
expect(error.message).toMatchSnapshot(
"Discount with id does-not-exist was not found"
)
}
})
it("throws if condition does not belong to discount", async () => {
const api = useApi()
const prod2 = await simpleProductFactory(dbConnection, { type: "pants" })
try {
await api.post(
"/admin/discounts/test-discount-2/conditions/test-condition",
{
products: [prod2.id],
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
} catch (error) {
expect(error.message).toMatchSnapshot(
"DiscountCondition with id test-condition was not found for Discount test-discount-2"
)
}
})
})
})
@@ -13,6 +13,7 @@ import faker from "faker"
import { Connection } from "typeorm"
export type DiscuntConditionFactoryData = {
id?: string
rule_id: string
type: DiscountConditionType
operator: DiscountConditionOperator
@@ -93,11 +94,17 @@ export const simpleDiscountConditionFactory = async (
resources = data.customer_groups
}
const condToSave = manager.create(DiscountCondition, {
const toCreate = {
type: data.type,
operator: data.operator,
discount_rule_id: data.rule_id,
})
}
if (data.id) {
toCreate["id"] = data.id
}
const condToSave = manager.create(DiscountCondition, toCreate)
const { conditionTable, resourceKey } = getJoinTableResourceIdentifiers(
data.type
@@ -2,8 +2,11 @@ import {
AdminDiscountsDeleteRes,
AdminDiscountsListRes,
AdminDiscountsRes,
AdminGetDiscountParams,
AdminGetDiscountsParams,
AdminPostDiscountsDiscountConditions,
AdminPostDiscountsDiscountConditionsCondition,
AdminPostDiscountsDiscountConditionsConditionParams,
AdminPostDiscountsDiscountConditionsParams,
AdminPostDiscountsDiscountDynamicCodesReq,
AdminPostDiscountsDiscountReq,
AdminPostDiscountsReq,
@@ -133,6 +136,57 @@ class AdminDiscountsResource extends BaseResource {
const path = `/admin/discounts/${id}/regions/${regionId}`
return this.client.request("DELETE", path, {}, {}, customHeaders)
}
/**
* @description creates a discount condition
*/
createCondition(
discountId: string,
payload: AdminPostDiscountsDiscountConditions,
query: AdminPostDiscountsDiscountConditionsParams = {},
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminDiscountsRes> {
let path = `/admin/discounts/${discountId}/conditions`
if (query) {
const queryString = qs.stringify(query)
path = `/admin/discounts/${discountId}/conditions?${queryString}`
}
return this.client.request("POST", path, payload, {}, customHeaders)
}
/**
* @description Updates a discount condition
*/
updateCondition(
discountId: string,
conditionId: string,
payload: AdminPostDiscountsDiscountConditionsCondition,
query: AdminPostDiscountsDiscountConditionsConditionParams = {},
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminDiscountsRes> {
let path = `/admin/discounts/${discountId}/conditions/${conditionId}`
if (query) {
const queryString = qs.stringify(query)
path = `/admin/discounts/${discountId}/conditions/${conditionId}?${queryString}`
}
return this.client.request("POST", path, payload, {}, customHeaders)
}
/**
* @description Removes a condition from a discount
*/
deleteCondition(
discountId: string,
conditionId: string,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminDiscountsDeleteRes> {
const path = `/admin/discounts/${discountId}/conditions/${conditionId}`
return this.client.request("DELETE", path, {}, {}, customHeaders)
}
}
export default AdminDiscountsResource
@@ -478,7 +478,14 @@
"created_at": "2021-03-16T21:24:16.872Z",
"updated_at": "2021-03-16T21:24:16.872Z",
"deleted_at": null,
"metadata": null
"metadata": null,
"conditions": [
{
"id": "cnd_01F0YESMVJXKX8XZ6Q9Z6Q6Z6",
"type": "products",
"operator": "in"
}
]
},
"is_disabled": false,
"parent_discount_id": null,
@@ -762,6 +762,43 @@ export const adminHandlers = [
)
}),
rest.post("/admin/discounts/:id/conditions", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
discount: {
...fixtures.get("discount"),
},
})
)
}),
rest.post("/admin/discounts/:id/conditions/:conditionId", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
discount: {
...fixtures.get("discount"),
},
})
)
}),
rest.delete(
"/admin/discounts/:id/conditions/:conditionId",
(req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
id: req.params.conditionId,
object: "discount-condition",
deleted: true,
discount: fixtures.get("discount"),
})
)
}
),
rest.get("/admin/draft-orders/", (req, res, ctx) => {
return res(
ctx.status(200),
@@ -1,9 +1,11 @@
import {
AdminDiscountsDeleteRes,
AdminDiscountsRes,
AdminPostDiscountsDiscountConditions,
AdminPostDiscountsDiscountConditionsCondition,
AdminPostDiscountsDiscountDynamicCodesReq,
AdminPostDiscountsDiscountReq,
AdminPostDiscountsReq,
AdminPostDiscountsReq
} from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { useMutation, UseMutationOptions, useQueryClient } from "react-query"
@@ -115,3 +117,51 @@ export const useAdminDeleteDynamicDiscountCode = (
)
)
}
export const useAdminDiscountCreateCondition = (
discountId: string,
options?: UseMutationOptions<
Response<AdminDiscountsRes>,
Error,
AdminPostDiscountsDiscountConditions
>
) => {
const { client } = useMedusa()
const queryClient = useQueryClient()
return useMutation(
(payload: AdminPostDiscountsDiscountConditions) =>
client.admin.discounts.createCondition(discountId, payload),
buildOptions(queryClient, adminDiscountKeys.detail(discountId), options)
)
}
export const useAdminDiscountUpdateCondition = (
discountId: string,
conditionId: string,
options?: UseMutationOptions<
Response<AdminDiscountsRes>,
Error,
AdminPostDiscountsDiscountConditionsCondition
>
) => {
const { client } = useMedusa()
const queryClient = useQueryClient()
return useMutation(
(payload: AdminPostDiscountsDiscountConditionsCondition) =>
client.admin.discounts.updateCondition(discountId, conditionId, payload),
buildOptions(queryClient, adminDiscountKeys.detail(discountId), options)
)
}
export const useAdminDiscountRemoveCondition = (
discountId: string,
options?: UseMutationOptions<Response<AdminDiscountsDeleteRes>, Error, string>
) => {
const { client } = useMedusa()
const queryClient = useQueryClient()
return useMutation(
(conditionId: string) =>
client.admin.discounts.deleteCondition(discountId, conditionId),
buildOptions(queryClient, adminDiscountKeys.detail(discountId), options)
)
}
@@ -1,3 +1,4 @@
import { DiscountConditionOperator } from "@medusajs/medusa"
import { renderHook } from "@testing-library/react-hooks"
import { fixtures } from "../../../../mocks/data"
import {
@@ -6,7 +7,10 @@ import {
useAdminDeleteDiscount,
useAdminDeleteDynamicDiscountCode,
useAdminDiscountAddRegion,
useAdminDiscountCreateCondition,
useAdminDiscountRemoveCondition,
useAdminDiscountRemoveRegion,
useAdminDiscountUpdateCondition,
useAdminUpdateDiscount,
} from "../../../../src/"
import { createWrapper } from "../../../utils"
@@ -184,3 +188,68 @@ describe("useAdminDeleteDynamicDiscountCode hook", () => {
)
})
})
describe("useAdminDiscountCreateCondition hook", () => {
test("Creates a condition from a discount", async () => {
const id = fixtures.get("discount").id
const conditionId = fixtures.get("discount").rule.conditions[0].id
const { result, waitFor } = renderHook(
() => useAdminDiscountCreateCondition(id),
{
wrapper: createWrapper(),
}
)
result.current.mutate({
operator: DiscountConditionOperator.IN,
products: ["test-product"],
})
await waitFor(() => result.current.isSuccess)
expect(result.current.data.response.status).toEqual(200)
expect(result.current.data.discount).toEqual(fixtures.get("discount"))
})
})
describe("useAdminDiscountUpdateCondition hook", () => {
test("Updates condition for discount", async () => {
const id = fixtures.get("discount").id
const conditionId = fixtures.get("discount").rule.conditions[0].id
const { result, waitFor } = renderHook(
() => useAdminDiscountUpdateCondition(id, conditionId),
{
wrapper: createWrapper(),
}
)
result.current.mutate({ products: ["test-product"] })
await waitFor(() => result.current.isSuccess)
expect(result.current.data.response.status).toEqual(200)
expect(result.current.data.discount).toEqual(fixtures.get("discount"))
})
})
describe("useAdminDiscountRemoveCondition hook", () => {
test("removes a condition from a discount", async () => {
const id = fixtures.get("discount").id
const conditionId = fixtures.get("discount").rule.conditions[0].id
const { result, waitFor } = renderHook(
() => useAdminDiscountRemoveCondition(id),
{
wrapper: createWrapper(),
}
)
result.current.mutate(conditionId)
await waitFor(() => result.current.isSuccess)
expect(result.current.data.response.status).toEqual(200)
expect(result.current.data.id).toEqual(conditionId)
})
})
@@ -0,0 +1,107 @@
import { IsOptional, IsString } from "class-validator"
import { defaultAdminDiscountsFields, defaultAdminDiscountsRelations } from "."
import { Discount } from "../../../../models/discount"
import { DiscountConditionOperator } from "../../../../models/discount-condition"
import { DiscountService } from "../../../../services"
import DiscountConditionService from "../../../../services/discount-condition"
import { AdminUpsertConditionsReq } from "../../../../types/discount"
import { getRetrieveConfig } from "../../../../utils/get-query-config"
import { validator } from "../../../../utils/validator"
/**
* @oas [post] /discounts/{discount_id}/conditions
* operationId: "PostDiscountsDiscountConditions"
* summary: "Creates a DiscountCondition"
* x-authenticated: true
* parameters:
* - (path) discount_id=* {string} The id of the Product.
* - (query) expand {string} (Comma separated) Which fields should be expanded in each product of the result.
* - (query) fields {string} (Comma separated) Which fields should be included in each product of the result.
* description: "Creates a DiscountCondition"
* requestBody:
* content:
* application/json:
* schema:
* properties:
* operator:
* description: Operator of the condition
* type: string
* items:
* properties:
* products:
* type: array
* description: list of products
* product_types:
* type: array
* description: list of product types
* product_collections:
* type: array
* description: list of product collections
* product_tags:
* type: array
* description: list of product tags
* customer_groups:
* type: array
* description: list of customer_groups
* tags:
* - Discount
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* discount:
* $ref: "#/components/schemas/discount"
*/
export default async (req, res) => {
const { discount_id } = req.params
const validatedCondition = await validator(
AdminPostDiscountsDiscountConditions,
req.body
)
const validatedParams = await validator(
AdminPostDiscountsDiscountConditionsParams,
req.query
)
const conditionService: DiscountConditionService = req.scope.resolve(
"discountConditionService"
)
const discountService: DiscountService = req.scope.resolve("discountService")
let discount = await discountService.retrieve(discount_id)
await conditionService.upsertCondition({
...validatedCondition,
rule_id: discount.rule_id,
})
const config = getRetrieveConfig<Discount>(
defaultAdminDiscountsFields,
defaultAdminDiscountsRelations,
validatedParams?.fields?.split(",") as (keyof Discount)[],
validatedParams?.expand?.split(",")
)
discount = await discountService.retrieve(discount.id, config)
res.status(200).json({ discount })
}
export class AdminPostDiscountsDiscountConditions extends AdminUpsertConditionsReq {
@IsString()
operator: DiscountConditionOperator
}
export class AdminPostDiscountsDiscountConditionsParams {
@IsString()
@IsOptional()
expand?: string
@IsString()
@IsOptional()
fields?: string
}
@@ -83,8 +83,6 @@ import { AdminPostDiscountsDiscountParams } from "./update-discount"
export default async (req, res) => {
const validated = await validator(AdminPostDiscountsReq, req.body)
console.log(validated.rule.conditions)
const validatedParams = await validator(
AdminPostDiscountsDiscountParams,
req.query
@@ -0,0 +1,109 @@
import { IsOptional, IsString } from "class-validator"
import { MedusaError } from "medusa-core-utils"
import { defaultAdminDiscountsFields, defaultAdminDiscountsRelations } from "."
import { Discount } from "../../../../models"
import { DiscountService } from "../../../../services"
import DiscountConditionService from "../../../../services/discount-condition"
import { getRetrieveConfig } from "../../../../utils/get-query-config"
import { validator } from "../../../../utils/validator"
/**
* @oas [delete] /discounts/{discount_id}/conditions/{condition_id}
* operationId: "DeleteDiscountsDiscountConditionsCondition"
* summary: "Delete a DiscountCondition"
* description: "Deletes a DiscountCondition"
* x-authenticated: true
* parameters:
* - (path) id=* {string} The id of the Discount
* - (path) condition_id=* {string} The id of the DiscountCondition
* tags:
* - Discount
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* id:
* type: string
* description: The id of the deleted DiscountCondition
* object:
* type: string
* description: The type of the object that was deleted.
* deleted:
* type: boolean
* discount:
* type: object
* description: The Discount to which the condition used to belong
*/
export default async (req, res) => {
const { discount_id, condition_id } = req.params
const validatedParams = await validator(
AdminDeleteDiscountsDiscountConditionsConditionParams,
req.query
)
const conditionService: DiscountConditionService = req.scope.resolve(
"discountConditionService"
)
const condition = await conditionService
.retrieve(condition_id)
.catch(() => void 0)
if (!condition) {
// resolves idempotently in case of non-existing condition
return res.json({
id: condition_id,
object: "discount-condition",
deleted: true,
})
}
const discountService: DiscountService = req.scope.resolve("discountService")
let discount = await discountService.retrieve(discount_id, {
relations: ["rule", "rule.conditions"],
})
const existsOnDiscount = discount.rule.conditions.some(
(c) => c.id === condition_id
)
if (!existsOnDiscount) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Condition with id ${condition_id} does not belong to Discount with id ${discount_id}`
)
}
await conditionService.delete(condition_id)
const config = getRetrieveConfig<Discount>(
defaultAdminDiscountsFields,
defaultAdminDiscountsRelations,
validatedParams?.fields?.split(",") as (keyof Discount)[],
validatedParams?.expand?.split(",")
)
discount = await discountService.retrieve(discount_id, config)
res.json({
id: condition_id,
object: "discount-condition",
deleted: true,
discount,
})
}
export class AdminDeleteDiscountsDiscountConditionsConditionParams {
@IsString()
@IsOptional()
expand?: string
@IsString()
@IsOptional()
fields?: string
}
@@ -49,6 +49,20 @@ export default (app) => {
middlewares.wrap(require("./remove-region").default)
)
// Discount condition management
route.post(
"/:discount_id/conditions/:condition_id",
middlewares.wrap(require("./update-condition").default)
)
route.post(
"/:discount_id/conditions",
middlewares.wrap(require("./create-condition").default)
)
route.delete(
"/:discount_id/conditions/:condition_id",
middlewares.wrap(require("./delete-condition").default)
)
return app
}
@@ -88,12 +102,15 @@ export type AdminDiscountsListRes = PaginatedResponse & {
}
export * from "./add-region"
export * from "./create-condition"
export * from "./create-discount"
export * from "./create-dynamic-code"
export * from "./delete-condition"
export * from "./delete-discount"
export * from "./delete-dynamic-code"
export * from "./get-discount"
export * from "./get-discount-by-code"
export * from "./list-discounts"
export * from "./remove-region"
export * from "./update-condition"
export * from "./update-discount"
@@ -0,0 +1,109 @@
import { IsOptional, IsString } from "class-validator"
import { defaultAdminDiscountsFields, defaultAdminDiscountsRelations } from "."
import { Discount } from "../../../../models/discount"
import { DiscountService } from "../../../../services"
import DiscountConditionService from "../../../../services/discount-condition"
import { AdminUpsertConditionsReq } from "../../../../types/discount"
import { getRetrieveConfig } from "../../../../utils/get-query-config"
import { validator } from "../../../../utils/validator"
/**
* @oas [post] /discounts/{discount_id}/conditions/{condition_id}
* operationId: "PostDiscountsDiscountConditionsCondition"
* summary: "Updates a DiscountCondition"
* x-authenticated: true
* parameters:
* - (path) discount_id=* {string} The id of the Product.
* - (query) expand {string} (Comma separated) Which fields should be expanded in each product of the result.
* - (query) fields {string} (Comma separated) Which fields should be included in each product of the result.
* description: "Updates a DiscountCondition"
* requestBody:
* content:
* application/json:
* required:
* - id
* schema:
* properties:
* items:
* properties:
* products:
* type: array
* description: list of products
* product_types:
* type: array
* description: list of product types
* product_collections:
* type: array
* description: list of product collections
* product_tags:
* type: array
* description: list of product tags
* customer_groups:
* type: array
* description: list of customer_groups
* tags:
* - Discount
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* discount:
* $ref: "#/components/schemas/discount"
*/
export default async (req, res) => {
const { discount_id, condition_id } = req.params
const validatedCondition = await validator(
AdminPostDiscountsDiscountConditionsCondition,
req.body
)
const validatedParams = await validator(
AdminPostDiscountsDiscountConditionsConditionParams,
req.query
)
const conditionService: DiscountConditionService = req.scope.resolve(
"discountConditionService"
)
const condition = await conditionService.retrieve(condition_id)
const discountService: DiscountService = req.scope.resolve("discountService")
let discount = await discountService.retrieve(discount_id)
const updateObj = {
...validatedCondition,
rule_id: discount.rule_id,
id: condition.id,
}
await conditionService.upsertCondition(updateObj)
const config = getRetrieveConfig<Discount>(
defaultAdminDiscountsFields,
defaultAdminDiscountsRelations,
validatedParams?.fields?.split(",") as (keyof Discount)[],
validatedParams?.expand?.split(",")
)
discount = await discountService.retrieve(discount.id, config)
res.status(200).json({ discount })
}
export class AdminPostDiscountsDiscountConditionsCondition extends AdminUpsertConditionsReq {}
export class AdminPostDiscountsDiscountConditionsConditionParams {
@IsString()
@IsOptional()
expand?: string
@IsString()
@IsOptional()
fields?: string
}
@@ -145,6 +145,10 @@ export class DiscountCondition {
@BeforeInsert()
private beforeInsert() {
if (this.id) {
return
}
const id = ulid()
this.id = `discon_${id}`
}
@@ -6,6 +6,7 @@ import {
Not,
Repository,
} from "typeorm"
import { Discount } from "../models"
import {
DiscountCondition,
DiscountConditionOperator,
@@ -35,6 +36,21 @@ type DiscountConditionResourceType = EntityTarget<
@EntityRepository(DiscountCondition)
export class DiscountConditionRepository extends Repository<DiscountCondition> {
async findOneWithDiscount(
conditionId: string,
discountId: string
): Promise<(DiscountCondition & { discount: Discount }) | undefined> {
return (await this.createQueryBuilder("condition")
.leftJoinAndMapOne(
"condition.discount",
Discount,
"discount",
`condition.discount_rule_id = discount.rule_id and discount.id = :discId and condition.id = :dcId`,
{ discId: discountId, dcId: conditionId }
)
.getOne()) as (DiscountCondition & { discount: Discount }) | undefined
}
getJoinTableResourceIdentifiers(type: string): {
joinTable: string
resourceKey: string
+1 -1
View File
@@ -1,5 +1,5 @@
import { flatten, groupBy, map, merge } from "lodash"
import { EntityRepository, Repository, FindManyOptions } from "typeorm"
import { EntityRepository, FindManyOptions, Repository } from "typeorm"
import { Order } from "../models/order"
@EntityRepository(Order)
@@ -0,0 +1,179 @@
import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { EntityManager } from "typeorm"
import { EventBusService } from "."
import { DiscountCondition, DiscountConditionType } from "../models"
import { DiscountConditionRepository } from "../repositories/discount-condition"
import { FindConfig } from "../types/common"
import { UpsertDiscountConditionInput } from "../types/discount"
import { PostgresError } from "../utils/exception-formatter"
/**
* Provides layer to manipulate discount conditions.
* @implements {BaseService}
*/
class DiscountConditionService extends BaseService {
protected readonly manager_: EntityManager
protected readonly discountConditionRepository_: typeof DiscountConditionRepository
protected readonly eventBus_: EventBusService
protected transactionManager_?: EntityManager
constructor({ manager, discountConditionRepository, eventBusService }) {
super()
this.manager_ = manager
this.discountConditionRepository_ = discountConditionRepository
this.eventBus_ = eventBusService
}
withTransaction(transactionManager: EntityManager): DiscountConditionService {
if (!transactionManager) {
return this
}
const cloned = new DiscountConditionService({
manager: transactionManager,
discountConditionRepository: this.discountConditionRepository_,
eventBusService: this.eventBus_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
async retrieve(
conditionId: string,
config?: FindConfig<DiscountCondition>
): Promise<DiscountCondition | never> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const conditionRepo = manager.getCustomRepository(
this.discountConditionRepository_
)
const query = this.buildQuery_({ id: conditionId }, config)
const condition = await conditionRepo.findOne(query)
if (!condition) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`DiscountCondition with id ${conditionId} was not found`
)
}
return condition
})
}
protected static resolveConditionType_(data: UpsertDiscountConditionInput):
| {
type: DiscountConditionType
resource_ids: string[]
}
| undefined {
switch (true) {
case !!data.products?.length:
return {
type: DiscountConditionType.PRODUCTS,
resource_ids: data.products!,
}
case !!data.product_collections?.length:
return {
type: DiscountConditionType.PRODUCT_COLLECTIONS,
resource_ids: data.product_collections!,
}
case !!data.product_types?.length:
return {
type: DiscountConditionType.PRODUCT_TYPES,
resource_ids: data.product_types!,
}
case !!data.product_tags?.length:
return {
type: DiscountConditionType.PRODUCT_TAGS,
resource_ids: data.product_tags!,
}
case !!data.customer_groups?.length:
return {
type: DiscountConditionType.CUSTOMER_GROUPS,
resource_ids: data.customer_groups!,
}
default:
return undefined
}
}
async upsertCondition(data: UpsertDiscountConditionInput): Promise<void> {
let resolvedConditionType
return await this.atomicPhase_(
async (manager: EntityManager) => {
resolvedConditionType =
DiscountConditionService.resolveConditionType_(data)
if (!resolvedConditionType) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Missing one of products, collections, tags, types or customer groups in data`
)
}
const discountConditionRepo: DiscountConditionRepository =
manager.getCustomRepository(this.discountConditionRepository_)
if (data.id) {
return await discountConditionRepo.addConditionResources(
data.id,
resolvedConditionType.resource_ids,
resolvedConditionType.type,
true
)
}
const created = discountConditionRepo.create({
discount_rule_id: data.rule_id,
operator: data.operator,
type: resolvedConditionType.type,
})
const discountCondition = await discountConditionRepo.save(created)
return await discountConditionRepo.addConditionResources(
discountCondition.id,
resolvedConditionType.resource_ids,
resolvedConditionType.type
)
},
async (err: { code: string }) => {
if (err.code === PostgresError.DUPLICATE_ERROR) {
// A unique key constraint failed meaning the combination of
// discount rule id, type, and operator already exists in the db.
throw new MedusaError(
MedusaError.Types.DUPLICATE_ERROR,
`Discount Condition with operator '${data.operator}' and type '${resolvedConditionType?.type}' already exist on a Discount Rule`
)
}
}
)
}
async delete(discountConditionId: string): Promise<DiscountCondition> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const conditionRepo = manager.getCustomRepository(
this.discountConditionRepository_
)
const condition = await conditionRepo.findOne({
where: { id: discountConditionId },
})
if (!condition) {
return Promise.resolve()
}
return await conditionRepo.remove(condition)
})
}
}
export default DiscountConditionService
+22 -106
View File
@@ -11,7 +11,6 @@ import {
} from "."
import { Cart } from "../models/cart"
import { Discount } from "../models/discount"
import { DiscountConditionType } from "../models/discount-condition"
import {
AllocationType as DiscountAllocation,
DiscountRule,
@@ -28,10 +27,10 @@ import {
CreateDynamicDiscountInput,
FilterableDiscountProps,
UpdateDiscountInput,
UpsertDiscountConditionInput,
} from "../types/discount"
import { isFuture, isPast } from "../utils/date-helpers"
import { formatException, PostgresError } from "../utils/exception-formatter"
import { formatException } from "../utils/exception-formatter"
import DiscountConditionService from "./discount-condition"
/**
* Provides layer to manipulate discounts.
@@ -43,6 +42,7 @@ class DiscountService extends BaseService {
private discountRuleRepository_: typeof DiscountRuleRepository
private giftCardRepository_: typeof GiftCardRepository
private discountConditionRepository_: typeof DiscountConditionRepository
private discountConditionService_: DiscountConditionService
private totalsService_: TotalsService
private productService_: ProductService
private regionService_: RegionService
@@ -54,6 +54,7 @@ class DiscountService extends BaseService {
discountRuleRepository,
giftCardRepository,
discountConditionRepository,
discountConditionService,
totalsService,
productService,
regionService,
@@ -77,6 +78,9 @@ class DiscountService extends BaseService {
/** @private @const {DiscountConditionRepository} */
this.discountConditionRepository_ = discountConditionRepository
/** @private @const {DiscountConditionRepository} */
this.discountConditionService_ = discountConditionService
/** @private @const {TotalsService} */
this.totalsService_ = totalsService
@@ -104,6 +108,7 @@ class DiscountService extends BaseService {
discountRuleRepository: this.discountRuleRepository_,
giftCardRepository: this.giftCardRepository_,
discountConditionRepository: this.discountConditionRepository_,
discountConditionService: this.discountConditionService_,
totalsService: this.totalsService_,
productService: this.productService_,
regionService: this.regionService_,
@@ -263,9 +268,13 @@ class DiscountService extends BaseService {
const result = await discountRepo.save(created)
if (conditions?.length) {
for (const cond of conditions) {
await this.upsertDiscountCondition_(result.id, cond)
}
await Promise.all(
conditions.map(async (cond) => {
await this.discountConditionService_
.withTransaction(manager)
.upsertCondition({ rule_id: result.rule_id, ...cond })
})
)
}
return result
@@ -382,9 +391,13 @@ class DiscountService extends BaseService {
}
if (conditions?.length) {
for (const cond of conditions) {
await this.upsertDiscountCondition_(discount.id, cond)
}
await Promise.all(
conditions.map(async (cond) => {
await this.discountConditionService_
.withTransaction(manager)
.upsertCondition({ rule_id: discount.rule_id, ...cond })
})
)
}
if (regions) {
@@ -573,103 +586,6 @@ class DiscountService extends BaseService {
})
}
resolveConditionType_(data: UpsertDiscountConditionInput):
| {
type: DiscountConditionType
resource_ids: string[]
}
| undefined {
switch (true) {
case !!data.products?.length:
return {
type: DiscountConditionType.PRODUCTS,
resource_ids: data.products!,
}
case !!data.product_collections?.length:
return {
type: DiscountConditionType.PRODUCT_COLLECTIONS,
resource_ids: data.product_collections!,
}
case !!data.product_types?.length:
return {
type: DiscountConditionType.PRODUCT_TYPES,
resource_ids: data.product_types!,
}
case !!data.product_tags?.length:
return {
type: DiscountConditionType.PRODUCT_TAGS,
resource_ids: data.product_tags!,
}
case !!data.customer_groups?.length:
return {
type: DiscountConditionType.CUSTOMER_GROUPS,
resource_ids: data.customer_groups!,
}
default:
return undefined
}
}
async upsertDiscountCondition_(
discountId: string,
data: UpsertDiscountConditionInput
): Promise<void> {
const resolvedConditionType = this.resolveConditionType_(data)
const res = this.atomicPhase_(
async (manager) => {
const discountConditionRepo: DiscountConditionRepository =
manager.getCustomRepository(this.discountConditionRepository_)
if (!resolvedConditionType) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Missing one of products, collections, tags, types or customer groups in data`
)
}
if (data.id) {
return await discountConditionRepo.addConditionResources(
data.id,
resolvedConditionType.resource_ids,
resolvedConditionType.type,
true
)
}
const discount = await this.retrieve(discountId, {
relations: ["rule", "rule.conditions"],
})
const created = discountConditionRepo.create({
discount_rule_id: discount.rule_id,
operator: data.operator,
type: resolvedConditionType.type,
})
const discountCondition = await discountConditionRepo.save(created)
return await discountConditionRepo.addConditionResources(
discountCondition.id,
resolvedConditionType.resource_ids,
resolvedConditionType.type
)
},
async (err: { code: string }) => {
if (err.code === PostgresError.DUPLICATE_ERROR) {
// A unique key constraint failed meaning the combination of
// discount rule id, type, and operator already exists in the db.
throw new MedusaError(
MedusaError.Types.DUPLICATE_ERROR,
`Discount Condition with operator '${data.operator}' and type '${resolvedConditionType?.type}' already exist on a Discount Rule`
)
}
}
)
return res
}
async validateDiscountForProduct(
discountRuleId: string,
productId: string | undefined
+9 -6
View File
@@ -672,7 +672,7 @@ class ProductService extends BaseService {
)
if (!product) {
return Promise.resolve()
return
}
await productRepo.softRemove(product)
@@ -1069,22 +1069,25 @@ class ProductService extends BaseService {
const productArray = Array.isArray(products) ? products : [products]
const priceSelectionStrategy =
this.priceSelectionStrategy_.withTransaction(manager)
const priceSelectionStrategy = this.priceSelectionStrategy_.withTransaction(
manager
)
const productsWithPrices = await Promise.all(
productArray.map(async (p) => {
if (p.variants?.length) {
p.variants = await Promise.all(
p.variants.map(async (v) => {
const prices =
await priceSelectionStrategy.calculateVariantPrice(v.id, {
const prices = await priceSelectionStrategy.calculateVariantPrice(
v.id,
{
region_id: regionId,
currency_code: currencyCode,
cart_id: cart_id,
customer_id: customer_id,
include_discount_prices,
})
}
)
return {
...v,
+1
View File
@@ -105,6 +105,7 @@ export class AdminUpsertConditionsReq {
}
export type UpsertDiscountConditionInput = {
rule_id?: string
id?: string
operator?: DiscountConditionOperator
products?: string[]