feat: Add DiscountConditions (#1230)

* feat: Add DiscountCondition entity + Join table per relation (#1146)

* feat: Convert DiscountService to TypeScript (#1149)

* feat: Add DiscountRepository + bulk insert and remove (#1156)

* feat: Add `conditions` to payload in `POST /discounts` and `POST /discounts/:id` (#1170)

* feat: Add DiscountRuleCondition entity

* fix relation

* fix join key

* Add discount rule condition repo

* add join table per relation

* Convert DiscountService to TypeScript

* feat: Add DiscountConditionRepository

* Add migration + remove use of valid_for

* revert changes to files, not done yet

* init work on create discount endpoint

* Add conditions to create discount endpoint

* Add conditions to update discount endpoint

* Add unique constraint to discount condition

* integration tests passing

* fix imports of models

* fix tests (excluding totals calculations)

* Fix commented code

* add unique constraint on discount condition

* Add generic way of generating retrieve configs

* Requested changes + ExactlyOne validator

* Remove isLocal flag from error handler

* Use postgres error constant

* remove commented code

* feat: Add `isValidForProduct` to check if Discount is valid for a given Product (#1172)

* feat: Add `canApplyForCustomer` to check if Discount is valid for customer groups (#1212)

* feat: Add `calculateDiscountForLineItem` (#1224)

* feat: Adds discount condition test factory (#1228)

* Remove use of valid_for

* Tests passing

* Remove valid_for form relations

* Add integration tests for applying discounts to cart
This commit is contained in:
Oliver Windall Juhl
2022-03-24 16:47:50 +01:00
committed by GitHub
parent b7f699654b
commit a610805917
60 changed files with 4805 additions and 2021 deletions
@@ -1,13 +1,12 @@
import { IsNumber, IsOptional, IsString } from "class-validator"
import { Type } from "class-transformer"
import { IsNumber, IsOptional, IsString } from "class-validator"
import omit from "lodash/omit"
import { validator } from "../../../../utils/validator"
import { CustomerGroupService } from "../../../../services"
import { CustomerGroup } from "../../../../models/customer-group"
import { FindConfig } from "../../../../types/common"
import { defaultAdminCustomerGroupsRelations } from "."
import { CustomerGroup } from "../../../../models/customer-group"
import { CustomerGroupService } from "../../../../services"
import { FindConfig } from "../../../../types/common"
import { FilterableCustomerGroupProps } from "../../../../types/customer-groups"
import { validator } from "../../../../utils/validator"
/**
* @oas [get] /customer-groups
@@ -48,7 +48,7 @@ describe("POST /admin/discounts/:discount_id/regions/:region_id", () => {
"metadata",
"valid_duration",
],
relations: ["rule", "parent_discount", "regions", "rule.valid_for"],
relations: ["rule", "parent_discount", "regions", "rule.conditions"],
}
)
@@ -48,7 +48,7 @@ describe("POST /admin/discounts/:discount_id/products/:product_id", () => {
"metadata",
"valid_duration",
],
relations: ["rule", "parent_discount", "regions", "rule.valid_for"],
relations: ["rule", "parent_discount", "regions", "rule.conditions"],
}
)
@@ -166,6 +166,49 @@ describe("POST /admin/discounts", () => {
})
})
describe("fails on xor constraint for conditions", () => {
let subject
beforeAll(async () => {
subject = await request("POST", "/admin/discounts", {
payload: {
code: "TEST",
rule: {
description: "Test",
type: "fixed",
value: 10,
allocation: "total",
conditions: [
{
products: ["product1"],
operator: "in",
product_types: ["producttype1"],
},
],
},
starts_at: "02/02/2021 13:45",
is_dynamic: true,
valid_duration: "P1Y2M03DT04H05M",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns error", () => {
expect(subject.body.message).toEqual(
`Only one of products, product_types is allowed, Only one of product_types, products is allowed`
)
})
})
describe("fails on invalid date intervals", () => {
let subject
@@ -24,7 +24,7 @@ const defaultRelations = [
"rule",
"parent_discount",
"regions",
"rule.valid_for",
"rule.conditions",
]
describe("GET /admin/discounts/:discount_id", () => {
@@ -24,7 +24,7 @@ const defaultRelations = [
"rule",
"parent_discount",
"regions",
"rule.valid_for",
"rule.conditions",
]
describe("DELETE /admin/discounts/:discount_id/regions/region_id", () => {
@@ -24,7 +24,7 @@ const defaultRelations = [
"rule",
"parent_discount",
"regions",
"rule.valid_for",
"rule.conditions",
]
describe("DELETE /admin/discounts/:discount_id/products/:product_id", () => {
@@ -11,11 +11,16 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import { defaultAdminDiscountsRelations } from "."
import { defaultAdminDiscountsFields, defaultAdminDiscountsRelations } from "."
import { Discount } from "../../../../models/discount"
import { DiscountConditionOperator } from "../../../../models/discount-condition"
import DiscountService from "../../../../services/discount"
import { IsGreaterThan } from "../../../../utils/validators/greater-than"
import { AdminUpsertConditionsReq } from "../../../../types/discount"
import { getRetrieveConfig } from "../../../../utils/get-query-config"
import { validator } from "../../../../utils/validator"
import { IsGreaterThan } from "../../../../utils/validators/greater-than"
import { IsISO8601Duration } from "../../../../utils/validators/iso8601-duration"
import { AdminPostDiscountsDiscountParams } from "./update-discount"
/**
* @oas [post] /discounts
* operationId: "PostDiscounts"
@@ -74,16 +79,30 @@ import { IsISO8601Duration } from "../../../../utils/validators/iso8601-duration
* discount:
* $ref: "#/components/schemas/discount"
*/
export default async (req, res) => {
const validated = await validator(AdminPostDiscountsReq, req.body)
const discountService: DiscountService = req.scope.resolve("discountService")
const created = await discountService.create(validated)
const discount = await discountService.retrieve(
created.id,
defaultAdminDiscountsRelations
console.log(validated.rule.conditions)
const validatedParams = await validator(
AdminPostDiscountsDiscountParams,
req.query
)
const discountService: DiscountService = req.scope.resolve("discountService")
const created = await discountService.create(validated)
const config = getRetrieveConfig<Discount>(
defaultAdminDiscountsFields,
defaultAdminDiscountsRelations,
validatedParams?.fields?.split(",") as (keyof Discount)[],
validatedParams?.expand?.split(",")
)
const discount = await discountService.retrieve(created.id, config)
res.status(200).json({ discount })
}
@@ -132,7 +151,7 @@ export class AdminPostDiscountsReq {
@IsObject()
@IsOptional()
metadata?: object
metadata?: Record<string, unknown>
}
export class AdminPostDiscountsDiscountRule {
@@ -153,6 +172,22 @@ export class AdminPostDiscountsDiscountRule {
@IsOptional()
@IsArray()
@IsString({ each: true })
valid_for?: string[]
@ValidateNested({ each: true })
@Type(() => AdminCreateCondition)
conditions?: AdminCreateCondition[]
}
export class AdminCreateCondition extends AdminUpsertConditionsReq {
@IsString()
operator: DiscountConditionOperator
}
export class AdminPostDiscountsParams {
@IsArray()
@IsOptional()
expand?: string[]
@IsArray()
@IsOptional()
fields?: string[]
}
@@ -5,6 +5,7 @@ import {
IsOptional,
IsString,
} from "class-validator"
import { defaultAdminDiscountsFields, defaultAdminDiscountsRelations } from "."
import DiscountService from "../../../../services/discount"
import { validator } from "../../../../utils/validator"
/**
@@ -16,6 +17,7 @@ import { validator } from "../../../../utils/validator"
* parameters:
* - (path) id=* {string} The id of the Discount to create the dynamic code from."
* - (body) code=* {string} The unique code that will be used to redeem the Discount.
* - (body) usage_limit=* {number} amount of times the discount can be applied
* - (body) metadata {object} An optional set of key-value paris to hold additional information.
* tags:
* - Discount
@@ -44,7 +46,8 @@ export default async (req, res) => {
)
const discount = await discountService.retrieve(created.id, {
relations: ["rule", "rule.valid_for", "regions"],
select: defaultAdminDiscountsFields,
relations: defaultAdminDiscountsRelations,
})
res.status(200).json({ discount })
@@ -1,3 +1,4 @@
import { defaultAdminDiscountsFields, defaultAdminDiscountsRelations } from "."
import DiscountService from "../../../../services/discount"
/**
@@ -28,7 +29,8 @@ export default async (req, res) => {
await discountService.deleteDynamicCode(discount_id, code)
const discount = await discountService.retrieve(discount_id, {
relations: ["rule", "rule.valid_for", "regions"],
select: defaultAdminDiscountsFields,
relations: defaultAdminDiscountsRelations,
})
res.status(200).json({ discount })
@@ -62,7 +62,7 @@ export default (app) => {
return app
}
export const defaultAdminDiscountsFields = [
export const defaultAdminDiscountsFields: (keyof Discount)[] = [
"id",
"code",
"is_dynamic",
@@ -84,7 +84,7 @@ export const defaultAdminDiscountsRelations = [
"rule",
"parent_discount",
"regions",
"rule.valid_for",
"rule.conditions",
]
export type AdminDiscountsRes = {
@@ -1,7 +1,6 @@
import { Type, Transform } from "class-transformer"
import { Transform, Type } from "class-transformer"
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
@@ -9,12 +8,10 @@ import {
} from "class-validator"
import _, { pickBy } from "lodash"
import { defaultAdminDiscountsFields, defaultAdminDiscountsRelations } from "."
import {
AllocationType,
DiscountRuleType,
} from "../../../../models/discount-rule"
import { Discount } from "../../../.."
import DiscountService from "../../../../services/discount"
import { DateComparisonOperator } from "../../../../types/common"
import { FindConfig } from "../../../../types/common"
import { AdminGetDiscountsDiscountRuleParams } from "../../../../types/discount"
import { validator } from "../../../../utils/validator"
/**
* @oas [get] /discounts
@@ -46,7 +43,7 @@ export default async (req, res) => {
const discountService: DiscountService = req.scope.resolve("discountService")
const listConfig = {
const listConfig: FindConfig<Discount> = {
select: defaultAdminDiscountsFields,
relations: defaultAdminDiscountsRelations,
skip: validated.offset,
@@ -69,17 +66,12 @@ export default async (req, res) => {
})
}
class AdminGetDiscountsDiscountRuleParams {
@IsOptional()
@IsEnum(DiscountRuleType)
type: DiscountRuleType
@IsOptional()
@IsEnum(AllocationType)
allocation: AllocationType
}
export class AdminGetDiscountsParams {
@ValidateNested()
@IsOptional()
@Type(() => AdminGetDiscountsDiscountRuleParams)
rule?: AdminGetDiscountsDiscountRuleParams
@IsString()
@IsOptional()
q?: string
@@ -94,11 +86,6 @@ export class AdminGetDiscountsParams {
@Transform(({ value }) => value === "true")
is_disabled?: boolean
@ValidateNested()
@IsOptional()
@Type(() => AdminGetDiscountsDiscountRuleParams)
rule?: AdminGetDiscountsDiscountRuleParams
@IsInt()
@IsOptional()
@Type(() => Number)
@@ -12,7 +12,11 @@ import {
ValidateNested,
} from "class-validator"
import { defaultAdminDiscountsFields, defaultAdminDiscountsRelations } from "."
import { Discount } from "../../../../models/discount"
import { DiscountConditionOperator } from "../../../../models/discount-condition"
import DiscountService from "../../../../services/discount"
import { AdminUpsertConditionsReq } from "../../../../types/discount"
import { getRetrieveConfig } from "../../../../utils/get-query-config"
import { validator } from "../../../../utils/validator"
import { IsGreaterThan } from "../../../../utils/validators/greater-than"
import { IsISO8601Duration } from "../../../../utils/validators/iso8601-duration"
@@ -73,12 +77,24 @@ export default async (req, res) => {
const { discount_id } = req.params
const validated = await validator(AdminPostDiscountsDiscountReq, req.body)
const validatedParams = await validator(
AdminPostDiscountsDiscountParams,
req.query
)
const discountService: DiscountService = req.scope.resolve("discountService")
await discountService.update(discount_id, validated)
const discount = await discountService.retrieve(discount_id, {
select: defaultAdminDiscountsFields,
relations: defaultAdminDiscountsRelations,
})
const config = getRetrieveConfig<Discount>(
defaultAdminDiscountsFields,
defaultAdminDiscountsRelations,
validatedParams?.fields?.split(",") as (keyof Discount)[],
validatedParams?.expand?.split(",")
)
const discount = await discountService.retrieve(discount_id, config)
res.status(200).json({ discount })
}
@@ -128,7 +144,7 @@ export class AdminPostDiscountsDiscountReq {
@IsObject()
@IsOptional()
metadata?: object
metadata?: Record<string, unknown>
}
export class AdminUpdateDiscountRule {
@@ -151,8 +167,29 @@ export class AdminUpdateDiscountRule {
@IsNotEmpty()
allocation: string
@IsArray()
@IsOptional()
@IsString({ each: true })
valid_for?: string[]
@IsArray()
@ValidateNested({ each: true })
@Type(() => AdminUpsertCondition)
conditions?: AdminUpsertCondition[]
}
export class AdminUpsertCondition extends AdminUpsertConditionsReq {
@IsString()
@IsOptional()
id?: string
@IsString()
@IsOptional()
operator: DiscountConditionOperator
}
export class AdminPostDiscountsDiscountParams {
@IsString()
@IsOptional()
expand?: string
@IsString()
@IsOptional()
fields?: string
}
@@ -56,7 +56,6 @@ export default async (req, res) => {
relations: [
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_methods",
"region",
"items",
@@ -8,7 +8,6 @@ const defaultRelations = [
"shipping_address",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_methods",
"payments",
"fulfillments",
@@ -1,8 +1,8 @@
import { Router } from "express"
import { Order } from "../../../.."
import middlewares from "../../../middlewares"
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
import "reflect-metadata"
import { Order } from "../../../.."
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
import middlewares from "../../../middlewares"
const route = Router()
@@ -231,7 +231,6 @@ export const defaultAdminOrdersRelations = [
"shipping_address",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_methods",
"payments",
"fulfillments",
@@ -332,7 +331,6 @@ export const allowedAdminOrdersRelations = [
"shipping_address",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_methods",
"payments",
"fulfillments",
@@ -1,7 +1,7 @@
import { defaultAdminTaxRatesFields, defaultAdminTaxRatesRelations } from "../"
import { pick } from "lodash"
import { FindConfig } from "../../../../../types/common"
import { defaultAdminTaxRatesFields, defaultAdminTaxRatesRelations } from "../"
import { TaxRate } from "../../../../.."
import { FindConfig } from "../../../../../types/common"
export function pickByConfig<T>(
obj: T | T[],
@@ -64,7 +64,9 @@ export function getListConfig(
expandFields = expand
}
const orderBy = order ?? { created_at: "DESC" }
const orderBy: Record<string, "DESC" | "ASC"> = order ?? {
created_at: "DESC",
}
return {
select: includeFields.length ? includeFields : defaultAdminTaxRatesFields,
@@ -125,23 +125,25 @@ export const defaultStoreCartRelations = [
"shipping_methods.shipping_option",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
]
export type StoreCartsRes = {
cart: Omit<Cart, "refundable_amount" | "refunded_total">
}
export type StoreCompleteCartRes = {
type: "cart"
data: Cart
} | {
type: "order"
data: Order
} | {
type: "swap"
data: Swap
}
export type StoreCompleteCartRes =
| {
type: "cart"
data: Cart
}
| {
type: "order"
data: Order
}
| {
type: "swap"
data: Swap
}
export type StoreCartsDeleteRes = DeleteResponse
@@ -39,7 +39,6 @@ export const defaultStoreOrdersRelations = [
"shipping_methods",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"customer",
"payments",
"region",
@@ -79,7 +78,6 @@ export const allowedStoreOrdersRelations = [
"shipping_methods",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"customer",
"payments",
"region",