feat(medusa,types): added buyget support for modules (#6159)

This commit is contained in:
Riqwan Thamir
2024-01-23 21:01:44 +01:00
committed by GitHub
parent 302323916b
commit 68d8daccd2
22 changed files with 1058 additions and 109 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/medusa": patch
"@medusajs/types": patch
---
feat(medusa,types): added buyget support for modules

View File

@@ -62,7 +62,7 @@ describe("POST /admin/promotions", () => {
)
})
it("should create a promotion successfully", async () => {
it("should create a standard promotion successfully", async () => {
const api = useApi() as any
const response = await api.post(
`/admin/promotions`,
@@ -148,4 +148,194 @@ describe("POST /admin/promotions", () => {
})
)
})
it("should throw an error if buy_rules params are not passed", async () => {
const api = useApi() as any
const { response } = await api
.post(
`/admin/promotions`,
{
code: "TEST",
type: PromotionType.BUYGET,
is_automatic: true,
application_method: {
target_type: "items",
type: "fixed",
allocation: "each",
value: "100",
max_quantity: 100,
target_rules: [
{
attribute: "test.test",
operator: "eq",
values: ["test1", "test2"],
},
],
},
rules: [
{
attribute: "test.test",
operator: "eq",
values: ["test1", "test2"],
},
],
},
adminHeaders
)
.catch((e) => e)
expect(response.status).toEqual(400)
expect(response.data.message).toEqual(
"Buy rules are required for buyget promotion type"
)
})
it("should throw an error if buy_rules params are not passed", async () => {
const api = useApi() as any
const { response } = await api
.post(
`/admin/promotions`,
{
code: "TEST",
type: PromotionType.BUYGET,
is_automatic: true,
application_method: {
target_type: "items",
type: "fixed",
allocation: "each",
value: "100",
max_quantity: 100,
buy_rules: [
{
attribute: "test.test",
operator: "eq",
values: ["test1", "test2"],
},
],
},
rules: [
{
attribute: "test.test",
operator: "eq",
values: ["test1", "test2"],
},
],
},
adminHeaders
)
.catch((e) => e)
expect(response.status).toEqual(400)
expect(response.data.message).toEqual(
"Target rules are required for buyget promotion type"
)
})
it("should create a buyget promotion successfully", async () => {
const api = useApi() as any
const response = await api.post(
`/admin/promotions`,
{
code: "TEST",
type: PromotionType.BUYGET,
is_automatic: true,
campaign: {
name: "test",
campaign_identifier: "test-1",
budget: {
type: "usage",
limit: 100,
},
},
application_method: {
target_type: "items",
type: "fixed",
allocation: "each",
value: "100",
max_quantity: 100,
apply_to_quantity: 1,
buy_rules_min_quantity: 1,
target_rules: [
{
attribute: "test.test",
operator: "eq",
values: ["test1", "test2"],
},
],
buy_rules: [
{
attribute: "test.test",
operator: "eq",
values: ["test1", "test2"],
},
],
},
rules: [
{
attribute: "test.test",
operator: "eq",
values: ["test1", "test2"],
},
],
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.promotion).toEqual(
expect.objectContaining({
id: expect.any(String),
code: "TEST",
type: "buyget",
is_automatic: true,
campaign: expect.objectContaining({
name: "test",
campaign_identifier: "test-1",
budget: expect.objectContaining({
type: "usage",
limit: 100,
}),
}),
application_method: expect.objectContaining({
value: 100,
max_quantity: 100,
type: "fixed",
target_type: "items",
allocation: "each",
apply_to_quantity: 1,
buy_rules_min_quantity: 1,
target_rules: [
expect.objectContaining({
operator: "eq",
attribute: "test.test",
values: expect.arrayContaining([
expect.objectContaining({ value: "test1" }),
expect.objectContaining({ value: "test2" }),
]),
}),
],
buy_rules: [
expect.objectContaining({
operator: "eq",
attribute: "test.test",
values: expect.arrayContaining([
expect.objectContaining({ value: "test1" }),
expect.objectContaining({ value: "test2" }),
]),
}),
],
}),
rules: [
expect.objectContaining({
operator: "eq",
attribute: "test.test",
values: expect.arrayContaining([
expect.objectContaining({ value: "test1" }),
expect.objectContaining({ value: "test2" }),
]),
}),
],
})
)
})
})

View File

@@ -74,28 +74,30 @@ describe("GET /admin/promotions", () => {
)
expect(response.status).toEqual(200)
expect(response.data.promotion).toEqual({
id: expect.any(String),
code: "TEST",
campaign: null,
is_automatic: false,
type: "standard",
created_at: expect.any(String),
updated_at: expect.any(String),
deleted_at: null,
application_method: {
expect(response.data.promotion).toEqual(
expect.objectContaining({
id: expect.any(String),
promotion: expect.any(Object),
value: 100,
type: "fixed",
target_type: "order",
max_quantity: 0,
allocation: null,
code: "TEST",
campaign: null,
is_automatic: false,
type: "standard",
created_at: expect.any(String),
updated_at: expect.any(String),
deleted_at: null,
},
})
application_method: expect.objectContaining({
id: expect.any(String),
promotion: expect.any(Object),
value: 100,
type: "fixed",
target_type: "order",
max_quantity: 0,
allocation: null,
created_at: expect.any(String),
updated_at: expect.any(String),
deleted_at: null,
}),
})
)
})
it("should get the requested promotion with filtered fields and relations", async () => {

View File

@@ -132,4 +132,58 @@ describe("POST /admin/promotions/:id", () => {
})
)
})
it("should update a buyget promotion successfully", async () => {
const createdPromotion = await promotionModuleService.create({
code: "PROMOTION_TEST",
type: PromotionType.BUYGET,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: "100",
apply_to_quantity: 1,
buy_rules_min_quantity: 1,
buy_rules: [
{
attribute: "product_collection.id",
operator: "eq",
values: ["pcol_towel"],
},
],
target_rules: [
{
attribute: "product.id",
operator: "eq",
values: "prod_mat",
},
],
},
})
const api = useApi() as any
const response = await api.post(
`/admin/promotions/${createdPromotion.id}`,
{
code: "TEST_TWO",
application_method: {
value: "200",
buy_rules_min_quantity: 6,
},
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.promotion).toEqual(
expect.objectContaining({
id: expect.any(String),
code: "TEST_TWO",
application_method: expect.objectContaining({
value: 200,
buy_rules_min_quantity: 6,
}),
})
)
})
})

View File

@@ -1,3 +1,4 @@
import { PromotionTypeValues } from "@medusajs/types"
import {
ApplicationMethodAllocation,
ApplicationMethodTargetType,
@@ -15,6 +16,7 @@ import {
IsOptional,
IsString,
Validate,
ValidateIf,
ValidateNested,
} from "class-validator"
import { FindParams, extendedFindParamsMixin } from "../../../types/common"
@@ -43,7 +45,7 @@ export class AdminPostPromotionsReq {
@IsOptional()
@IsEnum(PromotionType)
type?: PromotionType
type?: PromotionTypeValues
@IsOptional()
@IsString()
@@ -56,8 +58,8 @@ export class AdminPostPromotionsReq {
@IsNotEmpty()
@ValidateNested()
@Type(() => ApplicationMethod)
application_method: ApplicationMethod
@Type(() => ApplicationMethodsPostReq)
application_method: ApplicationMethodsPostReq
@IsOptional()
@IsArray()
@@ -83,7 +85,7 @@ export class PromotionRule {
values: string[]
}
export class ApplicationMethod {
export class ApplicationMethodsPostReq {
@IsOptional()
@IsString()
description?: string
@@ -113,6 +115,68 @@ export class ApplicationMethod {
@ValidateNested({ each: true })
@Type(() => PromotionRule)
target_rules?: PromotionRule[]
@ValidateIf((data) => data.type === PromotionType.BUYGET)
@IsArray()
@ValidateNested({ each: true })
@Type(() => PromotionRule)
buy_rules?: PromotionRule[]
@ValidateIf((data) => data.type === PromotionType.BUYGET)
@IsNotEmpty()
@IsNumber()
apply_to_quantity?: number
@ValidateIf((data) => data.type === PromotionType.BUYGET)
@IsNotEmpty()
@IsNumber()
buy_rules_min_quantity?: number
}
export class ApplicationMethodsMethodPostReq {
@IsOptional()
@IsString()
description?: string
@IsOptional()
@IsString()
value?: string
@IsOptional()
@IsNumber()
max_quantity?: number
@IsOptional()
@IsEnum(ApplicationMethodType)
type?: ApplicationMethodType
@IsOptional()
@IsEnum(ApplicationMethodTargetType)
target_type?: ApplicationMethodTargetType
@IsOptional()
@IsEnum(ApplicationMethodAllocation)
allocation?: ApplicationMethodAllocation
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => PromotionRule)
target_rules?: PromotionRule[]
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => PromotionRule)
buy_rules?: PromotionRule[]
@IsOptional()
@IsNumber()
apply_to_quantity?: number
@IsOptional()
@IsNumber()
buy_rules_min_quantity?: number
}
export class AdminPostPromotionsPromotionReq {
@@ -141,8 +205,8 @@ export class AdminPostPromotionsPromotionReq {
@IsOptional()
@ValidateNested()
@Type(() => ApplicationMethod)
application_method?: ApplicationMethod
@Type(() => ApplicationMethodsMethodPostReq)
application_method?: ApplicationMethodsMethodPostReq
@IsOptional()
@IsArray()

View File

@@ -1,6 +1,6 @@
import { ValidatorOptions } from "class-validator"
import { NextFunction, Request, Response } from "express"
import { ClassConstructor } from "../../types/global"
import { ValidatorOptions } from "class-validator"
import { validator } from "../../utils/validator"
export function transformBody<T>(

View File

@@ -428,6 +428,199 @@ describe("Promotion Service", () => {
"rules[].operator (doesnotexist) is invalid. It should be one of gte, lte, gt, lt, eq, ne, in"
)
})
it("should create a basic buyget promotion successfully", async () => {
const createdPromotion = await service
.create({
code: "PROMOTION_TEST",
type: PromotionType.BUYGET,
})
.catch((e) => e)
const [promotion] = await service.list({
id: [createdPromotion.id],
})
expect(promotion).toEqual(
expect.objectContaining({
code: "PROMOTION_TEST",
is_automatic: false,
type: PromotionType.BUYGET,
})
)
})
it("should throw an error when target_rules are not present for buyget promotion", async () => {
const error = await service
.create({
code: "PROMOTION_TEST",
type: PromotionType.BUYGET,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: "100",
buy_rules: [
{
attribute: "product_collection",
operator: "eq",
values: ["pcol_towel"],
},
],
},
})
.catch((e) => e)
expect(error.message).toContain(
"Target rules are required for buyget promotion type"
)
})
it("should throw an error when buy_rules are not present for buyget promotion", async () => {
const error = await service
.create({
code: "PROMOTION_TEST",
type: PromotionType.BUYGET,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: "100",
},
})
.catch((e) => e)
expect(error.message).toContain(
"Buy rules are required for buyget promotion type"
)
})
it("should throw an error when apply_to_quantity is not present for buyget promotion", async () => {
const error = await service
.create({
code: "PROMOTION_TEST",
type: PromotionType.BUYGET,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: "100",
buy_rules_min_quantity: 1,
buy_rules: [
{
attribute: "product_collection.id",
operator: "eq",
values: ["pcol_towel"],
},
],
target_rules: [
{
attribute: "product.id",
operator: "eq",
values: ["prod_mat"],
},
],
},
})
.catch((e) => e)
expect(error.message).toContain(
"apply_to_quantity is a required field for Promotion type of buyget"
)
})
it("should throw an error when buy_rules_min_quantity is not present for buyget promotion", async () => {
const error = await service
.create({
code: "PROMOTION_TEST",
type: PromotionType.BUYGET,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: "100",
apply_to_quantity: 1,
buy_rules: [
{
attribute: "product_collection.id",
operator: "eq",
values: ["pcol_towel"],
},
],
target_rules: [
{
attribute: "product.id",
operator: "eq",
values: ["prod_mat"],
},
],
},
})
.catch((e) => e)
expect(error.message).toContain(
"buy_rules_min_quantity is a required field for Promotion type of buyget"
)
})
it("should create a buyget promotion with rules successfully", async () => {
const createdPromotion = await service.create({
code: "PROMOTION_TEST",
type: PromotionType.BUYGET,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: "100",
apply_to_quantity: 1,
buy_rules_min_quantity: 1,
buy_rules: [
{
attribute: "product_collection.id",
operator: "eq",
values: ["pcol_towel"],
},
],
target_rules: [
{
attribute: "product.id",
operator: "eq",
values: "prod_mat",
},
],
},
})
expect(createdPromotion).toEqual(
expect.objectContaining({
code: "PROMOTION_TEST",
is_automatic: false,
type: PromotionType.BUYGET,
application_method: expect.objectContaining({
type: "fixed",
target_type: "items",
allocation: "across",
value: 100,
apply_to_quantity: 1,
buy_rules_min_quantity: 1,
target_rules: [
expect.objectContaining({
attribute: "product.id",
operator: "eq",
values: [expect.objectContaining({ value: "prod_mat" })],
}),
],
buy_rules: [
expect.objectContaining({
attribute: "product_collection.id",
operator: "eq",
values: [expect.objectContaining({ value: "pcol_towel" })],
}),
],
}),
})
)
})
})
describe("update", () => {
@@ -966,6 +1159,103 @@ describe("Promotion Service", () => {
})
})
describe("addPromotionBuyRules", () => {
let promotion
beforeEach(async () => {
;[promotion] = await service.create([
{
code: "TEST",
type: PromotionType.BUYGET,
application_method: {
type: "fixed",
target_type: "items",
allocation: "each",
value: "100",
max_quantity: 500,
apply_to_quantity: 1,
buy_rules_min_quantity: 1,
target_rules: [
{
attribute: "product.id",
operator: "in",
values: ["prod_1", "prod_2"],
},
],
buy_rules: [
{
attribute: "product_collection.id",
operator: "eq",
values: ["pcol_towel"],
},
],
},
},
])
})
it("should throw an error when promotion with id does not exist", async () => {
let error
try {
await service.addPromotionBuyRules("does-not-exist", [])
} catch (e) {
error = e
}
expect(error.message).toEqual(
"Promotion with id: does-not-exist was not found"
)
})
it("should throw an error when a id is not provided", async () => {
let error
try {
await service.addPromotionBuyRules(undefined as unknown as string, [])
} catch (e) {
error = e
}
expect(error.message).toEqual('"promotionId" must be defined')
})
it("should successfully create buy rules for a buyget promotion", async () => {
promotion = await service.addPromotionBuyRules(promotion.id, [
{
attribute: "product.id",
operator: "in",
values: ["prod_3", "prod_4"],
},
])
expect(promotion).toEqual(
expect.objectContaining({
id: promotion.id,
application_method: expect.objectContaining({
buy_rules: expect.arrayContaining([
expect.objectContaining({
attribute: "product_collection.id",
operator: "eq",
values: expect.arrayContaining([
expect.objectContaining({ value: "pcol_towel" }),
]),
}),
expect.objectContaining({
attribute: "product.id",
operator: "in",
values: expect.arrayContaining([
expect.objectContaining({ value: "prod_3" }),
expect.objectContaining({ value: "prod_4" }),
]),
}),
]),
}),
})
)
})
})
describe("removePromotionRules", () => {
let promotion
@@ -1108,4 +1398,88 @@ describe("Promotion Service", () => {
)
})
})
describe("removePromotionBuyRules", () => {
let promotion
beforeEach(async () => {
;[promotion] = await service.create([
{
code: "TEST",
type: PromotionType.BUYGET,
application_method: {
type: "fixed",
target_type: "items",
allocation: "each",
value: "100",
max_quantity: 500,
apply_to_quantity: 1,
buy_rules_min_quantity: 1,
target_rules: [
{
attribute: "product.id",
operator: "in",
values: ["prod_1", "prod_2"],
},
],
buy_rules: [
{
attribute: "product_collection",
operator: "eq",
values: ["pcol_towel"],
},
],
},
},
])
})
it("should throw an error when promotion with id does not exist", async () => {
let error
try {
await service.removePromotionBuyRules("does-not-exist", [])
} catch (e) {
error = e
}
expect(error.message).toEqual(
"Promotion with id: does-not-exist was not found"
)
})
it("should throw an error when a id is not provided", async () => {
let error
try {
await service.removePromotionBuyRules(
undefined as unknown as string,
[]
)
} catch (e) {
error = e
}
expect(error.message).toEqual('"promotionId" must be defined')
})
it("should successfully remove rules for a promotion", async () => {
const [ruleId] = promotion.application_method.buy_rules.map(
(rule) => rule.id
)
promotion = await service.removePromotionBuyRules(promotion.id, [
{ id: ruleId },
])
expect(promotion).toEqual(
expect.objectContaining({
id: promotion.id,
application_method: expect.objectContaining({
buy_rules: [],
}),
})
)
})
})
})

View File

@@ -398,6 +398,24 @@
"nullable": true,
"mappedType": "decimal"
},
"apply_to_quantity": {
"name": "apply_to_quantity",
"type": "numeric",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "decimal"
},
"buy_rules_min_quantity": {
"name": "buy_rules_min_quantity",
"type": "numeric",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "decimal"
},
"type": {
"name": "type",
"type": "text",
@@ -697,11 +715,11 @@
"mappedType": "text"
}
},
"name": "application_method_promotion_rule",
"name": "application_method_target_rules",
"schema": "public",
"indexes": [
{
"keyName": "application_method_promotion_rule_pkey",
"keyName": "application_method_target_rules_pkey",
"columnNames": ["application_method_id", "promotion_rule_id"],
"composite": true,
"primary": true,
@@ -710,19 +728,73 @@
],
"checks": [],
"foreignKeys": {
"application_method_promotion_rule_application_method_id_foreign": {
"constraintName": "application_method_promotion_rule_application_method_id_foreign",
"application_method_target_rules_application_method_id_foreign": {
"constraintName": "application_method_target_rules_application_method_id_foreign",
"columnNames": ["application_method_id"],
"localTableName": "public.application_method_promotion_rule",
"localTableName": "public.application_method_target_rules",
"referencedColumnNames": ["id"],
"referencedTableName": "public.application_method",
"deleteRule": "cascade",
"updateRule": "cascade"
},
"application_method_promotion_rule_promotion_rule_id_foreign": {
"constraintName": "application_method_promotion_rule_promotion_rule_id_foreign",
"application_method_target_rules_promotion_rule_id_foreign": {
"constraintName": "application_method_target_rules_promotion_rule_id_foreign",
"columnNames": ["promotion_rule_id"],
"localTableName": "public.application_method_promotion_rule",
"localTableName": "public.application_method_target_rules",
"referencedColumnNames": ["id"],
"referencedTableName": "public.promotion_rule",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
},
{
"columns": {
"application_method_id": {
"name": "application_method_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"promotion_rule_id": {
"name": "promotion_rule_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
}
},
"name": "application_method_buy_rules",
"schema": "public",
"indexes": [
{
"keyName": "application_method_buy_rules_pkey",
"columnNames": ["application_method_id", "promotion_rule_id"],
"composite": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"application_method_buy_rules_application_method_id_foreign": {
"constraintName": "application_method_buy_rules_application_method_id_foreign",
"columnNames": ["application_method_id"],
"localTableName": "public.application_method_buy_rules",
"referencedColumnNames": ["id"],
"referencedTableName": "public.application_method",
"deleteRule": "cascade",
"updateRule": "cascade"
},
"application_method_buy_rules_promotion_rule_id_foreign": {
"constraintName": "application_method_buy_rules_promotion_rule_id_foreign",
"columnNames": ["promotion_rule_id"],
"localTableName": "public.application_method_buy_rules",
"referencedColumnNames": ["id"],
"referencedTableName": "public.promotion_rule",
"deleteRule": "cascade",

View File

@@ -1,6 +1,6 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20240122070028 extends Migration {
export class Migration20240122084316 extends Migration {
async up(): Promise<void> {
this.addSql(
'create table "campaign" ("id" text not null, "name" text not null, "description" text null, "currency" text null, "campaign_identifier" text not null, "starts_at" timestamptz null, "ends_at" timestamptz null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "campaign_pkey" primary key ("id"));'
@@ -29,7 +29,7 @@ export class Migration20240122070028 extends Migration {
)
this.addSql(
'create table "application_method" ("id" text not null, "value" numeric null, "max_quantity" numeric null, "type" text check ("type" in (\'fixed\', \'percentage\')) not null, "target_type" text check ("target_type" in (\'order\', \'shipping_methods\', \'items\')) not null, "allocation" text check ("allocation" in (\'each\', \'across\')) null, "promotion_id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "application_method_pkey" primary key ("id"));'
'create table "application_method" ("id" text not null, "value" numeric null, "max_quantity" numeric null, "apply_to_quantity" numeric null, "buy_rules_min_quantity" numeric null, "type" text check ("type" in (\'fixed\', \'percentage\')) not null, "target_type" text check ("target_type" in (\'order\', \'shipping_methods\', \'items\')) not null, "allocation" text check ("allocation" in (\'each\', \'across\')) null, "promotion_id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "application_method_pkey" primary key ("id"));'
)
this.addSql(
'create index "IDX_application_method_type" on "application_method" ("type");'
@@ -59,7 +59,11 @@ export class Migration20240122070028 extends Migration {
)
this.addSql(
'create table "application_method_promotion_rule" ("application_method_id" text not null, "promotion_rule_id" text not null, constraint "application_method_promotion_rule_pkey" primary key ("application_method_id", "promotion_rule_id"));'
'create table "application_method_target_rules" ("application_method_id" text not null, "promotion_rule_id" text not null, constraint "application_method_target_rules_pkey" primary key ("application_method_id", "promotion_rule_id"));'
)
this.addSql(
'create table "application_method_buy_rules" ("application_method_id" text not null, "promotion_rule_id" text not null, constraint "application_method_buy_rules_pkey" primary key ("application_method_id", "promotion_rule_id"));'
)
this.addSql(
@@ -89,10 +93,17 @@ export class Migration20240122070028 extends Migration {
)
this.addSql(
'alter table "application_method_promotion_rule" add constraint "application_method_promotion_rule_application_method_id_foreign" foreign key ("application_method_id") references "application_method" ("id") on update cascade on delete cascade;'
'alter table "application_method_target_rules" add constraint "application_method_target_rules_application_method_id_foreign" foreign key ("application_method_id") references "application_method" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table "application_method_promotion_rule" add constraint "application_method_promotion_rule_promotion_rule_id_foreign" foreign key ("promotion_rule_id") references "promotion_rule" ("id") on update cascade on delete cascade;'
'alter table "application_method_target_rules" add constraint "application_method_target_rules_promotion_rule_id_foreign" foreign key ("promotion_rule_id") references "promotion_rule" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table "application_method_buy_rules" add constraint "application_method_buy_rules_application_method_id_foreign" foreign key ("application_method_id") references "application_method" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table "application_method_buy_rules" add constraint "application_method_buy_rules_promotion_rule_id_foreign" foreign key ("promotion_rule_id") references "promotion_rule" ("id") on update cascade on delete cascade;'
)
this.addSql(

View File

@@ -25,6 +25,8 @@ import PromotionRule from "./promotion-rule"
type OptionalFields =
| "value"
| "max_quantity"
| "apply_to_quantity"
| "buy_rules_min_quantity"
| "allocation"
| DAL.SoftDeletableEntityDateColumns
@@ -37,10 +39,16 @@ export default class ApplicationMethod {
id!: string
@Property({ columnType: "numeric", nullable: true, serializer: Number })
value?: string | null
value?: string | null = null
@Property({ columnType: "numeric", nullable: true, serializer: Number })
max_quantity?: number | null
max_quantity?: number | null = null
@Property({ columnType: "numeric", nullable: true, serializer: Number })
apply_to_quantity?: number | null = null
@Property({ columnType: "numeric", nullable: true, serializer: Number })
buy_rules_min_quantity?: number | null = null
@Index({ name: "IDX_application_method_type" })
@Enum(() => PromotionUtils.ApplicationMethodType)
@@ -63,13 +71,20 @@ export default class ApplicationMethod {
})
promotion: Promotion
@ManyToMany(() => PromotionRule, "application_methods", {
@ManyToMany(() => PromotionRule, "method_target_rules", {
owner: true,
pivotTable: "application_method_promotion_rule",
pivotTable: "application_method_target_rules",
cascade: ["soft-remove"] as any,
})
target_rules = new Collection<PromotionRule>(this)
@ManyToMany(() => PromotionRule, "method_buy_rules", {
owner: true,
pivotTable: "application_method_buy_rules",
cascade: ["soft-remove"] as any,
})
buy_rules = new Collection<PromotionRule>(this)
@Property({
onCreate: () => new Date(),
columnType: "timestamptz",

View File

@@ -35,7 +35,7 @@ export default class CampaignBudget {
@OneToOne({
entity: () => Campaign,
})
campaign?: Campaign | null
campaign: Campaign | null = null
@Property({
columnType: "numeric",
@@ -43,7 +43,7 @@ export default class CampaignBudget {
serializer: Number,
default: null,
})
limit?: number | null
limit: number | null = null
@Property({
columnType: "numeric",

View File

@@ -36,10 +36,10 @@ export default class Campaign {
name: string
@Property({ columnType: "text", nullable: true })
description?: string | null
description: string | null = null
@Property({ columnType: "text", nullable: true })
currency?: string | null
currency: string | null = null
@Property({ columnType: "text" })
@Unique({
@@ -52,13 +52,13 @@ export default class Campaign {
columnType: "timestamptz",
nullable: true,
})
starts_at?: Date | null
starts_at: Date | null = null
@Property({
columnType: "timestamptz",
nullable: true,
})
ends_at?: Date | null
ends_at: Date | null = null
@OneToOne({
entity: () => CampaignBudget,
@@ -66,7 +66,7 @@ export default class Campaign {
cascade: ["soft-remove"] as any,
nullable: true,
})
budget?: CampaignBudget | null
budget: CampaignBudget | null = null
@OneToMany(() => Promotion, (promotion) => promotion.campaign, {
orphanRemoval: true,

View File

@@ -31,7 +31,7 @@ export default class PromotionRule {
id!: string
@Property({ columnType: "text", nullable: true })
description?: string | null
description: string | null = null
@Index({ name: "IDX_promotion_rule_attribute" })
@Property({ columnType: "text" })
@@ -53,7 +53,13 @@ export default class PromotionRule {
() => ApplicationMethod,
(applicationMethod) => applicationMethod.target_rules
)
application_methods = new Collection<ApplicationMethod>(this)
method_target_rules = new Collection<ApplicationMethod>(this)
@ManyToMany(
() => ApplicationMethod,
(applicationMethod) => applicationMethod.buy_rules
)
method_buy_rules = new Collection<ApplicationMethod>(this)
@Property({
onCreate: () => new Date(),

View File

@@ -1,4 +1,4 @@
import { DAL, PromotionType } from "@medusajs/types"
import { DAL, PromotionTypeValues } from "@medusajs/types"
import { DALUtils, PromotionUtils, generateEntityId } from "@medusajs/utils"
import {
BeforeCreate,
@@ -45,14 +45,14 @@ export default class Promotion {
nullable: true,
cascade: ["soft-remove"] as any,
})
campaign?: Campaign | null
campaign: Campaign | null = null
@Property({ columnType: "boolean", default: false })
is_automatic: boolean = false
@Index({ name: "IDX_promotion_type" })
@Enum(() => PromotionUtils.PromotionType)
type: PromotionType
type: PromotionTypeValues
@OneToOne({
entity: () => ApplicationMethod,

View File

@@ -15,6 +15,7 @@ import {
InjectTransactionManager,
MedusaContext,
MedusaError,
PromotionType,
isString,
mapObjectTo,
} from "@medusajs/utils"
@@ -35,6 +36,7 @@ import {
PromotionService,
} from "@services"
import {
ApplicationMethodRuleTypes,
CreateApplicationMethodDTO,
CreateCampaignBudgetDTO,
CreateCampaignDTO,
@@ -456,6 +458,8 @@ export default class PromotionModuleService<
"application_method",
"application_method.target_rules",
"application_method.target_rules.values",
"application_method.buy_rules",
"application_method.buy_rules.values",
"rules",
"rules.values",
"campaign",
@@ -485,7 +489,11 @@ export default class PromotionModuleService<
string,
PromotionTypes.CreatePromotionRuleDTO[]
>()
const applicationMethodRuleMap = new Map<
const methodTargetRulesMap = new Map<
string,
PromotionTypes.CreatePromotionRuleDTO[]
>()
const methodBuyRulesMap = new Map<
string,
PromotionTypes.CreatePromotionRuleDTO[]
>()
@@ -551,6 +559,7 @@ export default class PromotionModuleService<
if (applMethodData) {
const {
target_rules: targetRulesData = [],
buy_rules: buyRulesData = [],
...applicationMethodWithoutRules
} = applMethodData
const applicationMethodData = {
@@ -569,11 +578,33 @@ export default class PromotionModuleService<
)
}
validateApplicationMethodAttributes(applicationMethodData)
if (promotion.type === PromotionType.BUYGET && !buyRulesData.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Buy rules are required for ${PromotionType.BUYGET} promotion type`
)
}
if (
promotion.type === PromotionType.BUYGET &&
!targetRulesData.length
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Target rules are required for ${PromotionType.BUYGET} promotion type`
)
}
validateApplicationMethodAttributes(applicationMethodData, promotion)
applicationMethodsData.push(applicationMethodData)
if (targetRulesData.length) {
applicationMethodRuleMap.set(promotion.id, targetRulesData)
methodTargetRulesMap.set(promotion.id, targetRulesData)
}
if (buyRulesData.length) {
methodBuyRulesMap.set(promotion.id, buyRulesData)
}
}
@@ -597,8 +628,15 @@ export default class PromotionModuleService<
for (const applicationMethod of createdApplicationMethods) {
await this.createPromotionRulesAndValues_(
applicationMethodRuleMap.get(applicationMethod.promotion.id) || [],
"application_methods",
methodTargetRulesMap.get(applicationMethod.promotion.id) || [],
"method_target_rules",
applicationMethod,
sharedContext
)
await this.createPromotionRulesAndValues_(
methodBuyRulesMap.get(applicationMethod.promotion.id) || [],
"method_buy_rules",
applicationMethod,
sharedContext
)
@@ -694,18 +732,10 @@ export default class PromotionModuleService<
existingApplicationMethod.max_quantity = null
}
validateApplicationMethodAttributes({
type: applicationMethodData.type || existingApplicationMethod.type,
target_type:
applicationMethodData.target_type ||
existingApplicationMethod.target_type,
allocation:
applicationMethodData.allocation ||
existingApplicationMethod.allocation,
max_quantity:
applicationMethodData.max_quantity ||
existingApplicationMethod.max_quantity,
})
validateApplicationMethodAttributes(
applicationMethodData,
existingPromotion
)
applicationMethodsData.push({
...applicationMethodData,
@@ -771,7 +801,7 @@ export default class PromotionModuleService<
await this.createPromotionRulesAndValues_(
rulesData,
"application_methods",
"method_target_rules",
applicationMethod,
sharedContext
)
@@ -791,10 +821,51 @@ export default class PromotionModuleService<
)
}
@InjectManager("baseRepository_")
async addPromotionBuyRules(
promotionId: string,
rulesData: PromotionTypes.CreatePromotionRuleDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.PromotionDTO> {
const promotion = await this.promotionService_.retrieve(promotionId, {
relations: ["application_method"],
})
const applicationMethod = promotion.application_method
if (!applicationMethod) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method for promotion not found`
)
}
await this.createPromotionRulesAndValues_(
rulesData,
"method_buy_rules",
applicationMethod,
sharedContext
)
return this.retrieve(
promotionId,
{
relations: [
"rules",
"rules.values",
"application_method",
"application_method.buy_rules",
"application_method.buy_rules.values",
],
},
sharedContext
)
}
@InjectTransactionManager("baseRepository_")
protected async createPromotionRulesAndValues_(
rulesData: PromotionTypes.CreatePromotionRuleDTO[],
relationName: "promotions" | "application_methods",
relationName: "promotions" | "method_target_rules" | "method_buy_rules",
relation: Promotion | ApplicationMethod,
@MedusaContext() sharedContext: Context = {}
) {
@@ -952,9 +1023,10 @@ export default class PromotionModuleService<
rulesData: PromotionTypes.RemovePromotionRuleDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.PromotionDTO> {
await this.removePromotionTargetRules_(
await this.removeApplicationMethodRules_(
promotionId,
rulesData,
ApplicationMethodRuleTypes.TARGET_RULES,
sharedContext
)
@@ -973,16 +1045,47 @@ export default class PromotionModuleService<
)
}
@InjectTransactionManager("baseRepository_")
protected async removePromotionTargetRules_(
@InjectManager("baseRepository_")
async removePromotionBuyRules(
promotionId: string,
rulesData: PromotionTypes.RemovePromotionRuleDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.PromotionDTO> {
await this.removeApplicationMethodRules_(
promotionId,
rulesData,
ApplicationMethodRuleTypes.BUY_RULES,
sharedContext
)
return this.retrieve(
promotionId,
{
relations: [
"rules",
"rules.values",
"application_method",
"application_method.buy_rules",
"application_method.buy_rules.values",
],
},
sharedContext
)
}
@InjectTransactionManager("baseRepository_")
protected async removeApplicationMethodRules_(
promotionId: string,
rulesData: PromotionTypes.RemovePromotionRuleDTO[],
relation:
| ApplicationMethodRuleTypes.TARGET_RULES
| ApplicationMethodRuleTypes.BUY_RULES,
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
const promotionRuleIds = rulesData.map((ruleData) => ruleData.id)
const promotion = await this.promotionService_.retrieve(
promotionId,
{ relations: ["application_method.target_rules"] },
{ relations: [`application_method.${relation}`] },
sharedContext
)
@@ -995,7 +1098,7 @@ export default class PromotionModuleService<
)
}
const targetRuleIdsToRemove = applicationMethod.target_rules
const targetRuleIdsToRemove = applicationMethod[relation]
.toArray()
.filter((rule) => promotionRuleIds.includes(rule.id))
.map((rule) => rule.id)

View File

@@ -14,6 +14,8 @@ export interface CreateApplicationMethodDTO {
value?: string | null
promotion: Promotion | string | PromotionDTO
max_quantity?: number | null
buy_rules_min_quantity?: number | null
apply_to_quantity?: number | null
}
export interface UpdateApplicationMethodDTO {
@@ -24,4 +26,6 @@ export interface UpdateApplicationMethodDTO {
value?: string | null
promotion?: Promotion | string | PromotionDTO
max_quantity?: number | null
buy_rules_min_quantity?: number | null
apply_to_quantity?: number | null
}

View File

@@ -9,3 +9,8 @@ export interface CreatePromotionRuleDTO {
export interface UpdatePromotionRuleDTO {
id: string
}
export enum ApplicationMethodRuleTypes {
TARGET_RULES = "target_rules",
BUY_RULES = "buy_rules",
}

View File

@@ -1,8 +1,8 @@
import { PromotionType } from "@medusajs/types"
import { PromotionTypeValues } from "@medusajs/types"
export interface CreatePromotionDTO {
code: string
type: PromotionType
type: PromotionTypeValues
is_automatic?: boolean
campaign?: string
}
@@ -10,8 +10,7 @@ export interface CreatePromotionDTO {
export interface UpdatePromotionDTO {
id: string
code?: string
// TODO: add this when buyget is available
// type: PromotionType
type?: PromotionTypeValues
is_automatic?: boolean
campaign?: string
}

View File

@@ -1,16 +1,17 @@
import {
ApplicationMethodAllocationValues,
ApplicationMethodTargetTypeValues,
ApplicationMethodTypeValues,
} from "@medusajs/types"
import {
ApplicationMethodAllocation,
ApplicationMethodTargetType,
ApplicationMethodType,
MedusaError,
PromotionType,
isDefined,
isPresent,
} from "@medusajs/utils"
import { Promotion } from "@models"
import {
CreateApplicationMethodDTO,
UpdateApplicationMethodDTO,
} from "../../types"
export const allowedAllocationTargetTypes: string[] = [
ApplicationMethodTargetType.SHIPPING_METHODS,
@@ -26,17 +27,40 @@ export const allowedAllocationForQuantity: string[] = [
ApplicationMethodAllocation.EACH,
]
export function validateApplicationMethodAttributes(data: {
type: ApplicationMethodTypeValues
target_type: ApplicationMethodTargetTypeValues
allocation?: ApplicationMethodAllocationValues
max_quantity?: number | null
}) {
export function validateApplicationMethodAttributes(
data: UpdateApplicationMethodDTO | CreateApplicationMethodDTO,
promotion: Promotion
) {
const applicationMethod = promotion?.application_method || {}
const buyRulesMinQuantity =
data.buy_rules_min_quantity || applicationMethod?.buy_rules_min_quantity
const applyToQuantity =
data.apply_to_quantity || applicationMethod?.apply_to_quantity
const targetType = data.target_type || applicationMethod?.target_type
const applicationMethodType = data.type || applicationMethod?.type
const maxQuantity = data.max_quantity || applicationMethod.max_quantity
const allocation = data.allocation || applicationMethod.allocation
const allTargetTypes: string[] = Object.values(ApplicationMethodTargetType)
if (promotion?.type === PromotionType.BUYGET) {
if (!isPresent(applyToQuantity)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`apply_to_quantity is a required field for Promotion type of ${PromotionType.BUYGET}`
)
}
if (!isPresent(buyRulesMinQuantity)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`buy_rules_min_quantity is a required field for Promotion type of ${PromotionType.BUYGET}`
)
}
}
if (
data.allocation === ApplicationMethodAllocation.ACROSS &&
isPresent(data.max_quantity)
allocation === ApplicationMethodAllocation.ACROSS &&
isPresent(maxQuantity)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
@@ -44,7 +68,7 @@ export function validateApplicationMethodAttributes(data: {
)
}
if (!allTargetTypes.includes(data.target_type)) {
if (!allTargetTypes.includes(targetType)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method.target_type should be one of ${allTargetTypes.join(
@@ -55,7 +79,7 @@ export function validateApplicationMethodAttributes(data: {
const allTypes: string[] = Object.values(ApplicationMethodType)
if (!allTypes.includes(data.type)) {
if (!allTypes.includes(applicationMethodType)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method.type should be one of ${allTypes.join(", ")}`
@@ -63,8 +87,8 @@ export function validateApplicationMethodAttributes(data: {
}
if (
allowedAllocationTargetTypes.includes(data.target_type) &&
!allowedAllocationTypes.includes(data.allocation || "")
allowedAllocationTargetTypes.includes(targetType) &&
!allowedAllocationTypes.includes(allocation || "")
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
@@ -80,7 +104,7 @@ export function validateApplicationMethodAttributes(data: {
ApplicationMethodAllocation
)
if (data.allocation && !allAllocationTypes.includes(data.allocation)) {
if (allocation && !allAllocationTypes.includes(allocation)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method.allocation should be one of ${allAllocationTypes.join(
@@ -90,9 +114,9 @@ export function validateApplicationMethodAttributes(data: {
}
if (
data.allocation &&
allowedAllocationForQuantity.includes(data.allocation) &&
!isDefined(data.max_quantity)
allocation &&
allowedAllocationForQuantity.includes(allocation) &&
!isDefined(maxQuantity)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,

View File

@@ -16,8 +16,11 @@ export interface ApplicationMethodDTO {
allocation?: ApplicationMethodAllocationValues
value?: string | null
max_quantity?: number | null
buy_rules_min_quantity?: number | null
apply_to_quantity?: number | null
promotion?: PromotionDTO | string
target_rules?: PromotionRuleDTO[]
buy_rules?: PromotionRuleDTO[]
}
export interface CreateApplicationMethodDTO {
@@ -26,8 +29,11 @@ export interface CreateApplicationMethodDTO {
allocation?: ApplicationMethodAllocationValues
value?: string | null
max_quantity?: number | null
buy_rules_min_quantity?: number | null
apply_to_quantity?: number | null
promotion?: PromotionDTO | string
target_rules?: CreatePromotionRuleDTO[]
buy_rules?: CreatePromotionRuleDTO[]
}
export interface UpdateApplicationMethodDTO {
@@ -37,6 +43,8 @@ export interface UpdateApplicationMethodDTO {
allocation?: ApplicationMethodAllocationValues
value?: string | null
max_quantity?: number | null
buy_rules_min_quantity?: number | null
apply_to_quantity?: number | null
promotion?: PromotionDTO | string
}

View File

@@ -8,12 +8,12 @@ import {
import { CampaignDTO } from "./campaign"
import { CreatePromotionRuleDTO, PromotionRuleDTO } from "./promotion-rule"
export type PromotionType = "standard" | "buyget"
export type PromotionTypeValues = "standard" | "buyget"
export interface PromotionDTO {
id: string
code?: string
type?: PromotionType
type?: PromotionTypeValues
is_automatic?: boolean
application_method?: ApplicationMethodDTO
rules?: PromotionRuleDTO[]
@@ -22,7 +22,7 @@ export interface PromotionDTO {
export interface CreatePromotionDTO {
code: string
type: PromotionType
type: PromotionTypeValues
is_automatic?: boolean
application_method?: CreateApplicationMethodDTO
rules?: CreatePromotionRuleDTO[]
@@ -34,7 +34,7 @@ export interface UpdatePromotionDTO {
id: string
is_automatic?: boolean
code?: string
type?: PromotionType
type?: PromotionTypeValues
application_method?: UpdateApplicationMethodDTO
campaign_id?: string
}
@@ -44,6 +44,6 @@ export interface FilterablePromotionProps
id?: string[]
code?: string[]
is_automatic?: boolean
type?: PromotionType[]
type?: PromotionTypeValues[]
budget_id?: string[]
}

View File

@@ -90,6 +90,12 @@ export interface IPromotionModuleService extends IModuleService {
sharedContext?: Context
): Promise<PromotionDTO>
addPromotionBuyRules(
promotionId: string,
rulesData: CreatePromotionRuleDTO[],
sharedContext?: Context
): Promise<PromotionDTO>
removePromotionRules(
promotionId: string,
rulesData: RemovePromotionRuleDTO[],
@@ -102,6 +108,12 @@ export interface IPromotionModuleService extends IModuleService {
sharedContext?: Context
): Promise<PromotionDTO>
removePromotionBuyRules(
promotionId: string,
rulesData: RemovePromotionRuleDTO[],
sharedContext?: Context
): Promise<PromotionDTO>
createCampaigns(
data: CreateCampaignDTO,
sharedContext?: Context