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
+547 -17
View File
@@ -1,5 +1,11 @@
const path = require("path")
const { Region, DiscountRule, Discount } = require("@medusajs/medusa")
const {
Region,
DiscountRule,
Discount,
Customer,
CustomerGroup,
} = require("@medusajs/medusa")
const setupServer = require("../../../helpers/setup-server")
const { useApi } = require("../../../helpers/use-api")
@@ -7,6 +13,10 @@ 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,
} = require("../../factories/simple-discount-factory")
jest.setTimeout(30000)
@@ -26,6 +36,153 @@ describe("/admin/discounts", () => {
medusaProcess.kill()
})
describe("GET /admin/discounts/: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],
},
{
type: "product_types",
operator: "not_in",
product_types: [prod.type_id],
},
],
},
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should retrieve discount with customer conditions created with factory", 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: [
{
type: "customer_groups",
operator: "in",
customer_groups: ["customer-group-1"],
},
],
},
})
const response = await api
.get(
"/admin/discounts/test-discount?expand=rule,rule.conditions,rule.conditions.customer_groups",
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
const disc = response.data.discount
expect(response.status).toEqual(200)
expect(disc).toEqual(
expect.objectContaining({
id: "test-discount",
code: "TEST",
})
)
expect(disc.rule.conditions).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "customer_groups",
operator: "in",
discount_rule_id: disc.rule.id,
}),
])
)
})
it("should retrieve discount with product conditions created with factory", async () => {
const api = useApi()
const response = await api
.get(
"/admin/discounts/test-discount?expand=rule,rule.conditions,rule.conditions.products,rule.conditions.product_types",
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
const disc = response.data.discount
expect(response.status).toEqual(200)
expect(disc).toEqual(
expect.objectContaining({
id: "test-discount",
code: "TEST",
})
)
expect(disc.rule.conditions).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "products",
operator: "in",
discount_rule_id: disc.rule.id,
}),
expect.objectContaining({
type: "product_types",
operator: "not_in",
discount_rule_id: disc.rule.id,
}),
])
)
})
})
describe("GET /admin/discounts", () => {
beforeEach(async () => {
const manager = dbConnection.manager
@@ -267,25 +424,398 @@ describe("/admin/discounts", () => {
usage_limit: 10,
})
)
})
const test = await api.get(
`/admin/discounts/${response.data.discount.id}`,
{ headers: { Authorization: "Bearer test_token" } }
)
it("creates a discount with conditions", async () => {
const api = useApi()
expect(test.status).toEqual(200)
expect(test.data.discount).toEqual(
expect.objectContaining({
code: "HELLOWORLD",
usage_limit: 10,
rule: expect.objectContaining({
value: 10,
type: "percentage",
description: "test",
allocation: "total",
}),
const product = await simpleProductFactory(dbConnection, {
type: "pants",
tags: ["ss22"],
})
const anotherProduct = await simpleProductFactory(dbConnection, {
type: "blouses",
tags: ["ss23"],
})
const response = await api
.post(
"/admin/discounts",
{
code: "HELLOWORLD",
rule: {
description: "test",
type: "percentage",
value: 10,
allocation: "total",
conditions: [
{
products: [product.id],
operator: "in",
},
{
products: [anotherProduct.id],
operator: "not_in",
},
{
product_types: [product.type_id],
operator: "not_in",
},
{
product_types: [anotherProduct.type_id],
operator: "in",
},
{
product_tags: [product.tags[0].id],
operator: "not_in",
},
{
product_tags: [anotherProduct.tags[0].id],
operator: "in",
},
],
},
usage_limit: 10,
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
)
expect(response.status).toEqual(200)
expect(response.data.discount.rule.conditions).toEqual([
expect.objectContaining({
type: "products",
operator: "in",
}),
expect.objectContaining({
type: "products",
operator: "not_in",
}),
expect.objectContaining({
type: "product_types",
operator: "not_in",
}),
expect.objectContaining({
type: "product_types",
operator: "in",
}),
expect.objectContaining({
type: "product_tags",
operator: "not_in",
}),
expect.objectContaining({
type: "product_tags",
operator: "in",
}),
])
})
it("creates a discount with conditions and updates said conditions", async () => {
const api = useApi()
const product = await simpleProductFactory(dbConnection, {
type: "pants",
})
const anotherProduct = await simpleProductFactory(dbConnection, {
type: "pants",
})
const response = await api
.post(
"/admin/discounts?expand=rule,rule.conditions",
{
code: "HELLOWORLD",
rule: {
description: "test",
type: "percentage",
value: 10,
allocation: "total",
conditions: [
{
products: [product.id],
operator: "in",
},
{
product_types: [product.type_id],
operator: "not_in",
},
],
},
usage_limit: 10,
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
expect(response.status).toEqual(200)
expect(response.data.discount.rule.conditions).toEqual([
expect.objectContaining({
type: "products",
operator: "in",
}),
expect.objectContaining({
type: "product_types",
operator: "not_in",
}),
])
const createdRule = response.data.discount.rule
const condsToUpdate = createdRule.conditions[0]
const updated = await api
.post(
`/admin/discounts/${response.data.discount.id}?expand=rule,rule.conditions,rule.conditions.products`,
{
rule: {
id: createdRule.id,
type: createdRule.type,
value: createdRule.value,
allocation: createdRule.allocation,
conditions: [
{
id: condsToUpdate.id,
products: [product.id, anotherProduct.id],
},
],
},
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
expect(updated.status).toEqual(200)
expect(updated.data.discount.rule.conditions).toEqual([
expect.objectContaining({
type: "products",
operator: "in",
products: expect.arrayContaining([
expect.objectContaining({
id: product.id,
}),
expect.objectContaining({
id: anotherProduct.id,
}),
]),
}),
expect.objectContaining({
type: "product_types",
operator: "not_in",
}),
])
})
it("fails to add condition on rule with existing comb. of type and operator", async () => {
const api = useApi()
const product = await simpleProductFactory(dbConnection, {
type: "pants",
})
const anotherProduct = await simpleProductFactory(dbConnection, {
type: "pants",
})
const response = await api
.post(
"/admin/discounts",
{
code: "HELLOWORLD",
rule: {
description: "test",
type: "percentage",
value: 10,
allocation: "total",
conditions: [
{
products: [product.id],
operator: "in",
},
],
},
usage_limit: 10,
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
expect(response.status).toEqual(200)
const createdRule = response.data.discount.rule
try {
await api.post(
`/admin/discounts/${response.data.discount.id}?expand=rule,rule.conditions,rule.conditions.products`,
{
rule: {
id: createdRule.id,
type: createdRule.type,
value: createdRule.value,
allocation: createdRule.allocation,
conditions: [
{
products: [anotherProduct.id],
operator: "in",
},
],
},
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
} catch (error) {
console.log(error)
expect(error.response.data.type).toEqual("duplicate_error")
expect(error.response.data.message).toEqual(
`Discount Condition with operator 'in' and type 'products' already exist on a Discount Rule`
)
}
})
it("fails if multiple types of resources are provided on create", async () => {
const api = useApi()
const product = await simpleProductFactory(dbConnection, {
type: "pants",
})
try {
await api.post(
"/admin/discounts",
{
code: "HELLOWORLD",
rule: {
description: "test",
type: "percentage",
value: 10,
allocation: "total",
conditions: [
{
products: [product.id],
product_types: [product.type_id],
operator: "in",
},
],
},
usage_limit: 10,
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
} catch (error) {
expect(error.response.data.type).toEqual("invalid_data")
expect(error.response.data.message).toEqual(
"Only one of products, product_types is allowed, Only one of product_types, products is allowed"
)
}
})
it("fails if multiple types of resources are provided on update", async () => {
const api = useApi()
const product = await simpleProductFactory(dbConnection, {
type: "pants",
})
const anotherProduct = await simpleProductFactory(dbConnection, {
type: "pants",
})
const response = await api
.post(
"/admin/discounts",
{
code: "HELLOWORLD",
rule: {
description: "test",
type: "percentage",
value: 10,
allocation: "total",
conditions: [
{
products: [product.id],
operator: "in",
},
],
},
usage_limit: 10,
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
expect(response.status).toEqual(200)
const createdRule = response.data.discount.rule
try {
await api.post(
`/admin/discounts/${response.data.discount.id}?expand=rule,rule.conditions,rule.conditions.products`,
{
rule: {
id: createdRule.id,
type: createdRule.type,
value: createdRule.value,
allocation: createdRule.allocation,
conditions: [
{
products: [anotherProduct.id],
product_types: [product.type_id],
operator: "in",
},
],
},
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
} catch (error) {
console.log(error)
expect(error.response.data.type).toEqual("invalid_data")
expect(error.response.data.message).toEqual(
`Only one of products, product_types is allowed, Only one of product_types, products is allowed`
)
}
})
it("creates a discount and updates it", async () => {
@@ -16,6 +16,16 @@ const { initDb, useDb } = require("../../../helpers/use-db")
const cartSeeder = require("../../helpers/cart-seeder")
const productSeeder = require("../../helpers/product-seeder")
const swapSeeder = require("../../helpers/swap-seeder")
const { simpleCartFactory } = require("../../factories")
const {
simpleDiscountFactory,
} = require("../../factories/simple-discount-factory")
const {
simpleCustomerFactory,
} = require("../../factories/simple-customer-factory")
const {
simpleCustomerGroupFactory,
} = require("../../factories/simple-customer-group-factory")
jest.setTimeout(30000)
@@ -354,6 +364,348 @@ describe("/store/carts", () => {
})
})
it("successfully passes customer conditions with `in` operator and applies discount", async () => {
const api = useApi()
await simpleCustomerFactory(dbConnection, {
id: "cus_1234",
email: "oli@medusajs.com",
groups: [
{
id: "customer-group-1",
name: "VIP Customer",
},
],
})
await simpleCustomerGroupFactory(dbConnection, {
id: "customer-group-2",
name: "Loyal",
})
await simpleCartFactory(
dbConnection,
{
id: "test-customer-discount",
region: {
id: "test-region",
name: "Test region",
tax_rate: 12,
},
customer: "cus_1234",
line_items: [
{
variant_id: "test-variant",
unit_price: 100,
},
],
},
100
)
await simpleDiscountFactory(dbConnection, {
id: "test-discount",
code: "TEST",
regions: ["test-region"],
rule: {
type: "percentage",
value: "10",
allocation: "total",
conditions: [
{
type: "customer_groups",
operator: "in",
customer_groups: ["customer-group-1", "customer-group-2"],
},
],
},
})
const response = await api.post("/store/carts/test-customer-discount", {
discounts: [{ code: "TEST" }],
})
const cartRes = response.data.cart
expect(cartRes.discounts).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "TEST",
}),
])
)
expect(response.status).toEqual(200)
})
it("successfully passes customer conditions with `not_in` operator and applies discount", async () => {
const api = useApi()
await simpleCustomerFactory(dbConnection, {
id: "cus_1234",
email: "oli@medusajs.com",
groups: [
{
id: "customer-group-2",
name: "VIP Customer",
},
],
})
await simpleCustomerGroupFactory(dbConnection, {
id: "customer-group-1",
name: "Customer group 1",
})
await simpleCustomerGroupFactory(dbConnection, {
id: "customer-group-3",
name: "Customer group 3",
})
await simpleCartFactory(
dbConnection,
{
id: "test-customer-discount",
region: {
id: "test-region",
name: "Test region",
tax_rate: 12,
},
customer: "cus_1234",
line_items: [
{
variant_id: "test-variant",
unit_price: 100,
},
],
},
100
)
await simpleDiscountFactory(dbConnection, {
id: "test-discount",
code: "TEST",
regions: ["test-region"],
rule: {
type: "percentage",
value: "10",
allocation: "total",
conditions: [
{
type: "customer_groups",
operator: "not_in",
customer_groups: ["customer-group-1", "customer-group-3"],
},
],
},
})
const response = await api.post("/store/carts/test-customer-discount", {
discounts: [{ code: "TEST" }],
})
const cartRes = response.data.cart
expect(cartRes.discounts).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "TEST",
}),
])
)
expect(response.status).toEqual(200)
})
it("successfully applies discount in case no conditions is defined for group", async () => {
const api = useApi()
await simpleCustomerFactory(dbConnection, {
id: "cus_1234",
email: "oli@medusajs.com",
groups: [
{
id: "customer-group-1",
name: "VIP Customer",
},
],
})
await simpleCartFactory(
dbConnection,
{
id: "test-customer-discount",
region: {
id: "test-region",
name: "Test region",
tax_rate: 12,
},
customer: "cus_1234",
line_items: [
{
variant_id: "test-variant",
unit_price: 100,
},
],
},
100
)
await simpleDiscountFactory(dbConnection, {
id: "test-discount",
code: "TEST",
regions: ["test-region"],
rule: {
type: "percentage",
value: "10",
allocation: "total",
},
})
const response = await api.post("/store/carts/test-customer-discount", {
discounts: [{ code: "TEST" }],
})
const cartRes = response.data.cart
expect(cartRes.discounts).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "TEST",
}),
])
)
expect(response.status).toEqual(200)
})
it("fails to apply discount if customer group is part of `not_in` conditions", async () => {
const api = useApi()
await simpleCustomerFactory(dbConnection, {
id: "cus_1234",
email: "oli@medusajs.com",
groups: [
{
id: "customer-group-1",
name: "VIP Customer",
},
],
})
await simpleCartFactory(
dbConnection,
{
id: "test-customer-discount",
region: {
id: "test-region",
name: "Test region",
tax_rate: 12,
},
customer: "cus_1234",
line_items: [
{
variant_id: "test-variant",
unit_price: 100,
},
],
},
100
)
await simpleDiscountFactory(dbConnection, {
id: "test-discount",
code: "TEST",
regions: ["test-region"],
rule: {
type: "percentage",
value: "10",
allocation: "total",
conditions: [
{
type: "customer_groups",
operator: "not_in",
customer_groups: ["customer-group-1"],
},
],
},
})
try {
await api.post("/store/carts/test-customer-discount", {
discounts: [{ code: "TEST" }],
})
} catch (error) {
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual(
"Discount is not valid for customer"
)
}
})
it("fails to apply discount if customer group is not part of `in` conditions", async () => {
const api = useApi()
await simpleCustomerFactory(dbConnection, {
id: "cus_1234",
email: "oli@medusajs.com",
groups: [
{
id: "customer-group-2",
name: "VIP Customer",
},
],
})
await simpleCustomerGroupFactory(dbConnection, {
id: "customer-group-1",
name: "Customer group 1",
})
await simpleCartFactory(
dbConnection,
{
id: "test-customer-discount",
region: {
id: "test-region",
name: "Test region",
tax_rate: 12,
},
customer: "cus_1234",
line_items: [
{
variant_id: "test-variant",
unit_price: 100,
},
],
},
100
)
await simpleDiscountFactory(dbConnection, {
id: "test-discount",
code: "TEST",
regions: ["test-region"],
rule: {
type: "percentage",
value: "10",
allocation: "total",
conditions: [
{
type: "customer_groups",
operator: "in",
customer_groups: ["customer-group-1"],
},
],
},
})
try {
await api.post("/store/carts/test-customer-discount", {
discounts: [{ code: "TEST" }],
})
} catch (error) {
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual(
"Discount is not valid for customer"
)
}
})
it("fails to apply expired discount", async () => {
expect.assertions(2)
const api = useApi()
@@ -1,16 +1,16 @@
import { Connection } from "typeorm"
import faker from "faker"
import { Cart } from "@medusajs/medusa"
import { RegionFactoryData, simpleRegionFactory } from "./simple-region-factory"
import {
LineItemFactoryData,
simpleLineItemFactory,
} from "./simple-line-item-factory"
import faker from "faker"
import { Connection } from "typeorm"
import {
AddressFactoryData,
simpleAddressFactory,
} from "./simple-address-factory"
import { simpleCustomerFactory } from "./simple-customer-factory"
import {
LineItemFactoryData,
simpleLineItemFactory,
} from "./simple-line-item-factory"
import { RegionFactoryData, simpleRegionFactory } from "./simple-region-factory"
import {
ShippingMethodFactoryData,
simpleShippingMethodFactory,
@@ -18,6 +18,7 @@ import {
export type CartFactoryData = {
id?: string
customer?: string | { email: string }
region?: RegionFactoryData | string
email?: string | null
line_items?: LineItemFactoryData[]
@@ -43,6 +44,22 @@ export const simpleCartFactory = async (
const region = await simpleRegionFactory(connection, data.region)
regionId = region.id
}
let customerId: string
if (typeof data.customer === "string") {
customerId = data.customer
} else {
if (data?.customer?.email) {
const customer = await simpleCustomerFactory(connection, data.customer)
customerId = customer.id
} else if (data.email) {
const customer = await simpleCustomerFactory(connection, {
email: data.email,
})
customerId = customer.id
}
}
const address = await simpleAddressFactory(connection, data.shipping_address)
const id = data.id || `simple-cart-${Math.random() * 1000}`
@@ -51,6 +68,7 @@ export const simpleCartFactory = async (
email:
typeof data.email !== "undefined" ? data.email : faker.internet.email(),
region_id: regionId,
customer_id: customerId,
shipping_address_id: address.id,
})
@@ -0,0 +1,46 @@
import { Customer } from "@medusajs/medusa"
import faker from "faker"
import { Connection } from "typeorm"
import {
CustomerGroupFactoryData,
simpleCustomerGroupFactory,
} from "./simple-customer-group-factory"
export type CustomerFactoryData = {
id?: string
email?: string
groups?: CustomerGroupFactoryData[]
}
export const simpleCustomerFactory = async (
connection: Connection,
data: CustomerFactoryData = {},
seed?: number
): Promise<Customer> => {
if (typeof seed !== "undefined") {
faker.seed(seed)
}
const manager = connection.manager
const customerId = data.id || `simple-customer-${Math.random() * 1000}`
const c = manager.create(Customer, {
id: customerId,
email: data.email,
})
const customer = await manager.save(c)
if (data.groups) {
const groups = []
for (const g of data.groups) {
const created = await simpleCustomerGroupFactory(connection, g)
groups.push(created)
}
customer.groups = groups
await manager.save(customer)
}
return customer
}
@@ -0,0 +1,31 @@
import { CustomerGroup } from "@medusajs/medusa"
import faker from "faker"
import { Connection } from "typeorm"
export type CustomerGroupFactoryData = {
id?: string
name?: string
}
export const simpleCustomerGroupFactory = async (
connection: Connection,
data: CustomerGroupFactoryData = {},
seed?: number
): Promise<CustomerGroup> => {
if (typeof seed !== "undefined") {
faker.seed(seed)
}
const manager = connection.manager
const customerGroupId =
data.id || `simple-customer-group-${Math.random() * 1000}`
const c = manager.create(CustomerGroup, {
id: customerGroupId,
name: data.name,
})
const group = await manager.save(c)
return group
}
@@ -0,0 +1,116 @@
import {
DiscountCondition,
DiscountConditionOperator,
DiscountConditionType,
} from "@medusajs/medusa/dist/models/discount-condition"
import { DiscountConditionCustomerGroup } from "@medusajs/medusa/dist/models/discount-condition-customer-group"
import { DiscountConditionProduct } from "@medusajs/medusa/dist/models/discount-condition-product"
import { DiscountConditionProductCollection } from "@medusajs/medusa/dist/models/discount-condition-product-collection"
import { DiscountConditionProductTag } from "@medusajs/medusa/dist/models/discount-condition-product-tag"
import { DiscountConditionProductType } from "@medusajs/medusa/dist/models/discount-condition-product-type"
import { DiscountConditionJoinTableForeignKey } from "@medusajs/medusa/dist/repositories/discount-condition"
import faker from "faker"
import { Connection } from "typeorm"
export type DiscuntConditionFactoryData = {
rule_id: string
type: DiscountConditionType
operator: DiscountConditionOperator
products: string[]
product_collections: string[]
product_types: string[]
product_tags: string[]
customer_groups: string[]
}
const getJoinTableResourceIdentifiers = (type: string) => {
let conditionTable: any
let resourceKey
switch (type) {
case DiscountConditionType.PRODUCTS: {
resourceKey = DiscountConditionJoinTableForeignKey.PRODUCT_ID
conditionTable = DiscountConditionProduct
break
}
case DiscountConditionType.PRODUCT_TYPES: {
resourceKey = DiscountConditionJoinTableForeignKey.PRODUCT_TYPE_ID
conditionTable = DiscountConditionProductType
break
}
case DiscountConditionType.PRODUCT_COLLECTIONS: {
resourceKey = DiscountConditionJoinTableForeignKey.PRODUCT_COLLECTION_ID
conditionTable = DiscountConditionProductCollection
break
}
case DiscountConditionType.PRODUCT_TAGS: {
resourceKey = DiscountConditionJoinTableForeignKey.PRODUCT_TAG_ID
conditionTable = DiscountConditionProductTag
break
}
case DiscountConditionType.CUSTOMER_GROUPS: {
resourceKey = DiscountConditionJoinTableForeignKey.CUSTOMER_GROUP_ID
conditionTable = DiscountConditionCustomerGroup
break
}
default:
break
}
return {
resourceKey,
conditionTable,
}
}
export const simpleDiscountConditionFactory = async (
connection: Connection,
data: DiscuntConditionFactoryData,
seed?: number
): Promise<void> => {
if (typeof seed !== "undefined") {
faker.seed(seed)
}
const manager = connection.manager
let resources = []
if (data.products) {
resources = data.products
}
if (data.product_collections) {
resources = data.product_collections
}
if (data.product_types) {
resources = data.product_types
}
if (data.product_tags) {
resources = data.product_tags
}
if (data.customer_groups) {
resources = data.customer_groups
}
const condToSave = manager.create(DiscountCondition, {
type: data.type,
operator: data.operator,
discount_rule_id: data.rule_id,
})
const { conditionTable, resourceKey } = getJoinTableResourceIdentifiers(
data.type
)
const condition = await manager.save(condToSave)
for (const resourceCond of resources) {
const toSave = manager.create(conditionTable, {
[resourceKey]: resourceCond,
condition_id: condition.id,
})
await manager.save(toSave)
}
}
@@ -1,16 +1,21 @@
import { Connection } from "typeorm"
import faker from "faker"
import {
AllocationType,
Discount,
DiscountRule,
DiscountRuleType,
AllocationType,
} from "@medusajs/medusa"
import faker from "faker"
import { Connection } from "typeorm"
import {
DiscuntConditionFactoryData,
simpleDiscountConditionFactory,
} from "./simple-discount-condition-factory"
export type DiscountRuleFactoryData = {
type?: DiscountRuleType
value?: number
allocation?: AllocationType
conditions: DiscuntConditionFactoryData[]
}
export type DiscountFactoryData = {
@@ -41,6 +46,16 @@ export const simpleDiscountFactory = async (
const dRule = await manager.save(ruleToSave)
if (data?.rule?.conditions) {
for (const condition of data.rule.conditions) {
await simpleDiscountConditionFactory(
connection,
{ ...condition, rule_id: dRule.id },
1
)
}
}
const toSave = manager.create(Discount, {
id: data.id,
is_dynamic: data.is_dynamic ?? false,
@@ -1,16 +1,16 @@
import { Connection } from "typeorm"
import faker from "faker"
import {
ShippingProfileType,
ShippingProfile,
Product,
ProductType,
ProductOption,
ProductTag,
ProductType,
ShippingProfile,
ShippingProfileType,
} from "@medusajs/medusa"
import faker from "faker"
import { Connection } from "typeorm"
import {
simpleProductVariantFactory,
ProductVariantFactoryData,
simpleProductVariantFactory,
} from "./simple-product-variant-factory"
export type ProductFactoryData = {
@@ -19,6 +19,7 @@ export type ProductFactoryData = {
status?: string
title?: string
type?: string
tags?: string[]
options?: { id: string; title: string }[]
variants?: ProductVariantFactoryData[]
}
@@ -42,27 +43,40 @@ export const simpleProductFactory = async (
type: ShippingProfileType.GIFT_CARD,
})
let typeId: string
const prodId = data.id || `simple-product-${Math.random() * 1000}`
const productToCreate = {
id: prodId,
title: data.title || faker.commerce.productName(),
is_giftcard: data.is_giftcard || false,
discountable: !data.is_giftcard,
tags: [],
profile_id: data.is_giftcard ? gcProfile.id : defaultProfile.id,
}
if (typeof data.tags !== "undefined") {
for (let i = 0; i < data.tags.length; i++) {
const createdTag = manager.create(ProductTag, {
id: `tag-${Math.random() * 1000}`,
value: data.tags[i],
})
const tagRes = await manager.save(createdTag)
productToCreate.tags.push(tagRes)
}
}
if (typeof data.type !== "undefined") {
const toSave = manager.create(ProductType, {
value: data.type,
})
const res = await manager.save(toSave)
typeId = res.id
productToCreate["type_id"] = res.id
}
const prodId = data.id || `simple-product-${Math.random() * 1000}`
const toSave = manager.create(Product, {
id: prodId,
type_id: typeId,
status: data.status,
title: data.title || faker.commerce.productName(),
is_giftcard: data.is_giftcard || false,
discountable: !data.is_giftcard,
profile_id: data.is_giftcard ? gcProfile.id : defaultProfile.id,
})
const toSave = manager.create(Product, productToCreate)
const product = await manager.save(toSave)
await manager.save(toSave)
const optionId = `${prodId}-option`
const options = data.options || [{ id: optionId, title: "Size" }]
@@ -97,5 +111,5 @@ export const simpleProductFactory = async (
await simpleProductVariantFactory(connection, factoryData)
}
return product
return await manager.findOne(Product, { id: prodId }, { relations: ["tags"] })
}
+3 -3
View File
@@ -8,16 +8,16 @@
"build": "babel src -d dist --extensions \".ts,.js\""
},
"dependencies": {
"@medusajs/medusa": "1.2.0-dev-1647336201011",
"@medusajs/medusa": "1.2.1-dev-1648026403166",
"faker": "^5.5.3",
"medusa-interfaces": "1.2.0-dev-1647336201011",
"medusa-interfaces": "1.2.1-dev-1648026403166",
"typeorm": "^0.2.31"
},
"devDependencies": {
"@babel/cli": "^7.12.10",
"@babel/core": "^7.12.10",
"@babel/node": "^7.12.10",
"babel-preset-medusa-package": "1.1.19-dev-1647336201011",
"babel-preset-medusa-package": "1.1.19-dev-1648026403166",
"jest": "^26.6.3"
}
}
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,3 @@
import { TotalsService } from "@medusajs/medusa"
import { humanizeAmount } from "medusa-core-utils"
import { FulfillmentService } from "medusa-interfaces"
import Webshipper from "../utils/webshipper"
@@ -114,13 +113,7 @@ class WebshipperFulfillmentService extends FulfillmentService {
const fromOrder = await this.orderService_.retrieve(orderId, {
select: ["total"],
relations: [
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_address",
"returns",
],
relations: ["discounts", "discounts.rule", "shipping_address", "returns"],
})
const methodData = returnOrder.shipping_method.data
@@ -145,7 +138,7 @@ class WebshipperFulfillmentService extends FulfillmentService {
}
}
let docs = []
const docs = []
if (this.invoiceGenerator_) {
const base64Invoice = await this.invoiceGenerator_.createReturnInvoice(
fromOrder,
@@ -338,7 +331,7 @@ class WebshipperFulfillmentService extends FulfillmentService {
}
}
let id = fulfillment.id
const id = fulfillment.id
let visible_ref = `${fromOrder.display_id}-${id.substr(id.length - 4)}`
let ext_ref = `${fromOrder.id}.${fulfillment.id}`
@@ -45,7 +45,6 @@ class OrderSubscriber {
"shipping_address",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_methods",
"payments",
"fulfillments",
@@ -149,7 +148,6 @@ class OrderSubscriber {
"shipping_address",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_methods",
"payments",
"fulfillments",
@@ -258,7 +256,6 @@ class OrderSubscriber {
"shipping_address",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_methods",
"payments",
"fulfillments",
@@ -1,7 +1,6 @@
import SendGrid from "@sendgrid/mail"
import { NotificationService } from "medusa-interfaces"
import { humanizeAmount, zeroDecimalCurrencies } from "medusa-core-utils"
import { NotificationService } from "medusa-interfaces"
class SendGridService extends NotificationService {
static identifier = "sendgrid"
@@ -293,7 +292,7 @@ class SendGridService extends NotificationService {
* @param {string} from - sender of email
* @param {string} to - receiver of email
* @param {Object} data - data to send in mail (match with template)
* @returns {Promise} result of the send operation
* @return {Promise} result of the send operation
*/
async sendEmail(options) {
try {
@@ -321,7 +320,6 @@ class SendGridService extends NotificationService {
"shipping_address",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_methods",
"shipping_methods.shipping_option",
"payments",
@@ -366,7 +364,6 @@ class SendGridService extends NotificationService {
"shipping_address",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_methods",
"shipping_methods.shipping_option",
"payments",
@@ -465,7 +462,6 @@ class SendGridService extends NotificationService {
"shipping_address",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_methods",
"shipping_methods.shipping_option",
"payments",
@@ -624,7 +620,6 @@ class SendGridService extends NotificationService {
"items.tax_lines",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_address",
"returns",
],
@@ -747,7 +742,6 @@ class SendGridService extends NotificationService {
"items",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_address",
"swaps",
"swaps.additional_items",
@@ -875,7 +869,6 @@ class SendGridService extends NotificationService {
"items.tax_lines",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_address",
"swaps",
"swaps.additional_items",
@@ -985,7 +978,6 @@ class SendGridService extends NotificationService {
"items.tax_lines",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"swaps",
"swaps.additional_items",
"swaps.additional_items.tax_lines",
@@ -1,5 +1,5 @@
import axios from "axios"
import { zeroDecimalCurrencies, humanizeAmount } from "medusa-core-utils"
import { humanizeAmount, zeroDecimalCurrencies } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
class SlackService extends BaseService {
@@ -41,7 +41,6 @@ class SlackService extends BaseService {
"shipping_address",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_methods",
"payments",
"fulfillments",
@@ -69,7 +68,7 @@ class SlackService extends BaseService {
return humanAmount.toFixed(2)
}
let blocks = [
const blocks = [
{
type: "section",
text: {
@@ -147,7 +146,7 @@ class SlackService extends BaseService {
include_tax: true,
}
)
let line = {
const line = {
type: "section",
text: {
type: "mrkdwn",
@@ -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",
@@ -0,0 +1,245 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class discountConditions1646324713514 implements MigrationInterface {
name = "discountConditions1646324713514"
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "discount_condition_type_enum" AS ENUM('products', 'product_types', 'product_collections', 'product_tags', 'customer_groups')`
)
await queryRunner.query(
`CREATE TYPE "discount_condition_operator_enum" AS ENUM('in', 'not_in')`
)
await queryRunner.query(
`CREATE TABLE "discount_condition" ("id" character varying NOT NULL, "type" "discount_condition_type_enum" NOT NULL, "operator" "discount_condition_operator_enum" NOT NULL, "discount_rule_id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP WITH TIME ZONE, "metadata" jsonb, CONSTRAINT "PK_e6b81d83133ddc21a2baf2e2204" PRIMARY KEY ("id"), CONSTRAINT "dctypeuniq" UNIQUE ("type", "operator", "discount_rule_id"))`
)
await queryRunner.query(
`CREATE INDEX "IDX_efff700651718e452ca9580a62" ON "discount_condition" ("discount_rule_id") `
)
await queryRunner.query(
`CREATE TABLE "discount_condition_customer_group" ("customer_group_id" character varying NOT NULL, "condition_id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "metadata" jsonb, CONSTRAINT "PK_cdc8b2277169a16b8b7d4c73e0e" PRIMARY KEY ("customer_group_id", "condition_id"))`
)
await queryRunner.query(
`CREATE TABLE "discount_condition_product_collection" ("product_collection_id" character varying NOT NULL, "condition_id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "metadata" jsonb, CONSTRAINT "PK_b3508fc787aa4a38705866cbb6d" PRIMARY KEY ("product_collection_id", "condition_id"))`
)
await queryRunner.query(
`CREATE TABLE "discount_condition_product_tag" ("product_tag_id" character varying NOT NULL, "condition_id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "metadata" jsonb, CONSTRAINT "PK_a95382c1e62205b121aa058682b" PRIMARY KEY ("product_tag_id", "condition_id"))`
)
await queryRunner.query(
`CREATE TABLE "discount_condition_product_type" ("product_type_id" character varying NOT NULL, "condition_id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "metadata" jsonb, CONSTRAINT "PK_35d538a5a24399d0df978df12ed" PRIMARY KEY ("product_type_id", "condition_id"))`
)
await queryRunner.query(
`CREATE TABLE "discount_condition_product" ("product_id" character varying NOT NULL, "condition_id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "metadata" jsonb, CONSTRAINT "PK_994eb4529fdbf14450d64ec17e8" PRIMARY KEY ("product_id", "condition_id"))`
)
await queryRunner.query(
`CREATE INDEX "IDX_f05132301e95bdab4ba1cf29a2" ON "discount_condition_product" ("condition_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_c759f53b2e48e8cfb50638fe4e" ON "discount_condition_product" ("product_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_6ef23ce0b1d9cf9b5b833e52b9" ON "discount_condition_product_type" ("condition_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_e706deb68f52ab2756119b9e70" ON "discount_condition_product_type" ("product_type_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_fbb2499551ed074526f3ee3624" ON "discount_condition_product_tag" ("condition_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_01486cc9dc6b36bf658685535f" ON "discount_condition_product_tag" ("product_tag_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_a1c4f9cfb599ad1f0db39cadd5" ON "discount_condition_product_collection" ("condition_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_a0b05dc4257abe639cb75f8eae" ON "discount_condition_product_collection" ("product_collection_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_8486ee16e69013c645d0b8716b" ON "discount_condition_customer_group" ("condition_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_4d5f98645a67545d8dea42e2eb" ON "discount_condition_customer_group" ("customer_group_id") `
)
await queryRunner.query(
`ALTER TABLE "discount_condition" ADD CONSTRAINT "FK_efff700651718e452ca9580a624" FOREIGN KEY ("discount_rule_id") REFERENCES "discount_rule"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_customer_group" ADD CONSTRAINT "FK_4d5f98645a67545d8dea42e2eb8" FOREIGN KEY ("customer_group_id") REFERENCES "customer_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_customer_group" ADD CONSTRAINT "FK_8486ee16e69013c645d0b8716b6" FOREIGN KEY ("condition_id") REFERENCES "discount_condition"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_collection" ADD CONSTRAINT "FK_a0b05dc4257abe639cb75f8eae2" FOREIGN KEY ("product_collection_id") REFERENCES "product_collection"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_collection" ADD CONSTRAINT "FK_a1c4f9cfb599ad1f0db39cadd5f" FOREIGN KEY ("condition_id") REFERENCES "discount_condition"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_tag" ADD CONSTRAINT "FK_01486cc9dc6b36bf658685535f6" FOREIGN KEY ("product_tag_id") REFERENCES "product_tag"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_tag" ADD CONSTRAINT "FK_fbb2499551ed074526f3ee36241" FOREIGN KEY ("condition_id") REFERENCES "discount_condition"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_type" ADD CONSTRAINT "FK_e706deb68f52ab2756119b9e704" FOREIGN KEY ("product_type_id") REFERENCES "product_type"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_type" ADD CONSTRAINT "FK_6ef23ce0b1d9cf9b5b833e52b9d" FOREIGN KEY ("condition_id") REFERENCES "discount_condition"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product" ADD CONSTRAINT "FK_c759f53b2e48e8cfb50638fe4e0" FOREIGN KEY ("product_id") REFERENCES "product"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product" ADD CONSTRAINT "FK_f05132301e95bdab4ba1cf29a24" FOREIGN KEY ("condition_id") REFERENCES "discount_condition"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "discount_condition_product" DROP CONSTRAINT "FK_f05132301e95bdab4ba1cf29a24"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product" DROP CONSTRAINT "FK_c759f53b2e48e8cfb50638fe4e0"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_type" DROP CONSTRAINT "FK_6ef23ce0b1d9cf9b5b833e52b9d"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_type" DROP CONSTRAINT "FK_e706deb68f52ab2756119b9e704"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_tag" DROP CONSTRAINT "FK_fbb2499551ed074526f3ee36241"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_tag" DROP CONSTRAINT "FK_01486cc9dc6b36bf658685535f6"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_collection" DROP CONSTRAINT "FK_a1c4f9cfb599ad1f0db39cadd5f"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_collection" DROP CONSTRAINT "FK_a0b05dc4257abe639cb75f8eae2"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_customer_group" DROP CONSTRAINT "FK_8486ee16e69013c645d0b8716b6"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_customer_group" DROP CONSTRAINT "FK_4d5f98645a67545d8dea42e2eb8"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition" DROP CONSTRAINT "FK_efff700651718e452ca9580a624"`
)
await queryRunner.query(`DROP INDEX "IDX_4d5f98645a67545d8dea42e2eb"`)
await queryRunner.query(`DROP INDEX "IDX_8486ee16e69013c645d0b8716b"`)
await queryRunner.query(`DROP INDEX "IDX_a0b05dc4257abe639cb75f8eae"`)
await queryRunner.query(`DROP INDEX "IDX_a1c4f9cfb599ad1f0db39cadd5"`)
await queryRunner.query(`DROP INDEX "IDX_01486cc9dc6b36bf658685535f"`)
await queryRunner.query(`DROP INDEX "IDX_fbb2499551ed074526f3ee3624"`)
await queryRunner.query(`DROP INDEX "IDX_e706deb68f52ab2756119b9e70"`)
await queryRunner.query(`DROP INDEX "IDX_6ef23ce0b1d9cf9b5b833e52b9"`)
await queryRunner.query(`DROP INDEX "IDX_c759f53b2e48e8cfb50638fe4e"`)
await queryRunner.query(`DROP INDEX "IDX_f05132301e95bdab4ba1cf29a2"`)
await queryRunner.query(
`ALTER TABLE "discount_condition_product" DROP COLUMN "metadata"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product" DROP COLUMN "updated_at"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product" DROP COLUMN "created_at"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_type" DROP COLUMN "metadata"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_type" DROP COLUMN "updated_at"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_type" DROP COLUMN "created_at"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_tag" DROP COLUMN "metadata"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_tag" DROP COLUMN "updated_at"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_tag" DROP COLUMN "created_at"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_collection" DROP COLUMN "metadata"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_collection" DROP COLUMN "updated_at"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_collection" DROP COLUMN "created_at"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_customer_group" DROP COLUMN "metadata"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_customer_group" DROP COLUMN "updated_at"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_customer_group" DROP COLUMN "created_at"`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_customer_group" ADD "metadata" jsonb`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_customer_group" ADD "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_customer_group" ADD "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_collection" ADD "metadata" jsonb`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_collection" ADD "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_collection" ADD "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_tag" ADD "metadata" jsonb`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_tag" ADD "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_tag" ADD "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_type" ADD "metadata" jsonb`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_type" ADD "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product_type" ADD "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product" ADD "metadata" jsonb`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product" ADD "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`
)
await queryRunner.query(
`ALTER TABLE "discount_condition_product" ADD "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`
)
await queryRunner.query(`DROP TABLE "discount_condition_product"`)
await queryRunner.query(`DROP TABLE "discount_condition_product_type"`)
await queryRunner.query(`DROP TABLE "discount_condition_product_tag"`)
await queryRunner.query(
`DROP TABLE "discount_condition_product_collection"`
)
await queryRunner.query(`DROP TABLE "discount_condition_customer_group"`)
await queryRunner.query(`DROP INDEX "IDX_efff700651718e452ca9580a62"`)
await queryRunner.query(`DROP TABLE "discount_condition"`)
await queryRunner.query(`DROP TYPE "discount_condition_operator_enum"`)
await queryRunner.query(`DROP TYPE "discount_condition_type_enum"`)
}
}
@@ -0,0 +1,66 @@
import {
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from "typeorm"
import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column"
import { CustomerGroup } from "./customer-group"
import { DiscountCondition } from "./discount-condition"
@Entity()
export class DiscountConditionCustomerGroup {
@PrimaryColumn()
customer_group_id: string
@PrimaryColumn()
condition_id: string
@ManyToOne(() => CustomerGroup, { onDelete: "CASCADE" })
@JoinColumn({ name: "customer_group_id" })
customer_group?: CustomerGroup
@ManyToOne(() => DiscountCondition, { onDelete: "CASCADE" })
@JoinColumn({ name: "condition_id" })
discount_condition?: DiscountCondition
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
@UpdateDateColumn({ type: resolveDbType("timestamptz") })
updated_at: Date
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: any
}
/**
* @schema discount_condition_customer_group
* title: "Product Tag Discount Condition"
* description: "Associates a discount condition with a customer group"
* x-resourceId: discount_condition_customer_group
* properties:
* customer_group_id:
* description: "The id of the Product Tag"
* type: string
* condition_id:
* description: "The id of the Discount Condition"
* type: string
* created_at:
* description: "The date with timezone at which the resource was created."
* type: string
* format: date-time
* updated_at:
* description: "The date with timezone at which the resource was last updated."
* type: string
* format: date-time
* deleted_at:
* description: "The date with timezone at which the resource was deleted."
* type: string
* format: date-time
* metadata:
* description: "An optional key-value map with additional information."
* type: object
*/
@@ -0,0 +1,66 @@
import {
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from "typeorm"
import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column"
import { DiscountCondition } from "./discount-condition"
import { ProductCollection } from "./product-collection"
@Entity()
export class DiscountConditionProductCollection {
@PrimaryColumn()
product_collection_id: string
@PrimaryColumn()
condition_id: string
@ManyToOne(() => ProductCollection, { onDelete: "CASCADE" })
@JoinColumn({ name: "product_collection_id" })
product_collection?: ProductCollection
@ManyToOne(() => DiscountCondition, { onDelete: "CASCADE" })
@JoinColumn({ name: "condition_id" })
discount_condition?: DiscountCondition
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
@UpdateDateColumn({ type: resolveDbType("timestamptz") })
updated_at: Date
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: any
}
/**
* @schema discount_condition_product_collection
* title: "Product Collection Discount Condition"
* description: "Associates a discount condition with a product collection"
* x-resourceId: discount_condition_product_collection
* properties:
* product_collection_id:
* description: "The id of the Product Collection"
* type: string
* condition_id:
* description: "The id of the Discount Condition"
* type: string
* created_at:
* description: "The date with timezone at which the resource was created."
* type: string
* format: date-time
* updated_at:
* description: "The date with timezone at which the resource was last updated."
* type: string
* format: date-time
* deleted_at:
* description: "The date with timezone at which the resource was deleted."
* type: string
* format: date-time
* metadata:
* description: "An optional key-value map with additional information."
* type: object
*/
@@ -0,0 +1,66 @@
import {
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from "typeorm"
import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column"
import { DiscountCondition } from "./discount-condition"
import { ProductTag } from "./product-tag"
@Entity()
export class DiscountConditionProductTag {
@PrimaryColumn()
product_tag_id: string
@PrimaryColumn()
condition_id: string
@ManyToOne(() => ProductTag, { onDelete: "CASCADE" })
@JoinColumn({ name: "product_tag_id" })
product_tag?: ProductTag
@ManyToOne(() => DiscountCondition, { onDelete: "CASCADE" })
@JoinColumn({ name: "condition_id" })
discount_condition?: DiscountCondition
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
@UpdateDateColumn({ type: resolveDbType("timestamptz") })
updated_at: Date
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: any
}
/**
* @schema discount_condition_product_tag
* title: "Product Tag Discount Condition"
* description: "Associates a discount condition with a product tag"
* x-resourceId: discount_condition_product_tag
* properties:
* product_tag_id:
* description: "The id of the Product Tag"
* type: string
* condition_id:
* description: "The id of the Discount Condition"
* type: string
* created_at:
* description: "The date with timezone at which the resource was created."
* type: string
* format: date-time
* updated_at:
* description: "The date with timezone at which the resource was last updated."
* type: string
* format: date-time
* deleted_at:
* description: "The date with timezone at which the resource was deleted."
* type: string
* format: date-time
* metadata:
* description: "An optional key-value map with additional information."
* type: object
*/
@@ -0,0 +1,66 @@
import {
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from "typeorm"
import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column"
import { DiscountCondition } from "./discount-condition"
import { ProductType } from "./product-type"
@Entity()
export class DiscountConditionProductType {
@PrimaryColumn()
product_type_id: string
@PrimaryColumn()
condition_id: string
@ManyToOne(() => ProductType, { onDelete: "CASCADE" })
@JoinColumn({ name: "product_type_id" })
product_type?: ProductType
@ManyToOne(() => DiscountCondition, { onDelete: "CASCADE" })
@JoinColumn({ name: "condition_id" })
discount_condition?: DiscountCondition
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
@UpdateDateColumn({ type: resolveDbType("timestamptz") })
updated_at: Date
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: any
}
/**
* @schema discount_condition_product_type
* title: "Product Type Discount Condition"
* description: "Associates a discount condition with a product type"
* x-resourceId: discount_condition_product
* properties:
* product_type_id:
* description: "The id of the Product Type"
* type: string
* condition_id:
* description: "The id of the Discount Condition"
* type: string
* created_at:
* description: "The date with timezone at which the resource was created."
* type: string
* format: date-time
* updated_at:
* description: "The date with timezone at which the resource was last updated."
* type: string
* format: date-time
* deleted_at:
* description: "The date with timezone at which the resource was deleted."
* type: string
* format: date-time
* metadata:
* description: "An optional key-value map with additional information."
* type: object
*/
@@ -0,0 +1,66 @@
import {
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from "typeorm"
import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column"
import { DiscountCondition } from "./discount-condition"
import { Product } from "./product"
@Entity()
export class DiscountConditionProduct {
@PrimaryColumn()
product_id: string
@PrimaryColumn()
condition_id: string
@ManyToOne(() => Product, { onDelete: "CASCADE" })
@JoinColumn({ name: "product_id" })
product?: Product
@ManyToOne(() => DiscountCondition, { onDelete: "CASCADE" })
@JoinColumn({ name: "condition_id" })
discount_condition?: DiscountCondition
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
@UpdateDateColumn({ type: resolveDbType("timestamptz") })
updated_at: Date
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: any
}
/**
* @schema discount_condition_product
* title: "Product Discount Condition"
* description: "Associates a discount condition with a product"
* x-resourceId: discount_condition_product
* properties:
* product_id:
* description: "The id of the Product"
* type: string
* condition_id:
* description: "The id of the Discount Condition"
* type: string
* created_at:
* description: "The date with timezone at which the resource was created."
* type: string
* format: date-time
* updated_at:
* description: "The date with timezone at which the resource was last updated."
* type: string
* format: date-time
* deleted_at:
* description: "The date with timezone at which the resource was deleted."
* type: string
* format: date-time
* metadata:
* description: "An optional key-value map with additional information."
* type: object
*/
@@ -0,0 +1,186 @@
import {
BeforeInsert,
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
PrimaryColumn,
Unique,
UpdateDateColumn,
} from "typeorm"
import { ulid } from "ulid"
import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column"
import { CustomerGroup } from "./customer-group"
import { DiscountRule } from "./discount-rule"
import { Product } from "./product"
import { ProductCollection } from "./product-collection"
import { ProductTag } from "./product-tag"
import { ProductType } from "./product-type"
export enum DiscountConditionType {
PRODUCTS = "products",
PRODUCT_TYPES = "product_types",
PRODUCT_COLLECTIONS = "product_collections",
PRODUCT_TAGS = "product_tags",
CUSTOMER_GROUPS = "customer_groups",
}
export enum DiscountConditionOperator {
IN = "in",
NOT_IN = "not_in",
}
@Entity()
@Unique("dctypeuniq", ["type", "operator", "discount_rule_id"])
export class DiscountCondition {
@PrimaryColumn()
id: string
@DbAwareColumn({
type: "enum",
enum: DiscountConditionType,
})
type: DiscountConditionType
@DbAwareColumn({
type: "enum",
enum: DiscountConditionOperator,
})
operator: DiscountConditionOperator
@Index()
@Column()
discount_rule_id: string
@ManyToOne(() => DiscountRule, (dr) => dr.conditions)
@JoinColumn({ name: "discount_rule_id" })
discount_rule: DiscountRule
@ManyToMany(() => Product)
@JoinTable({
name: "discount_condition_product",
joinColumn: {
name: "condition_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "product_id",
referencedColumnName: "id",
},
})
products: Product[]
@ManyToMany(() => ProductType)
@JoinTable({
name: "discount_condition_product_type",
joinColumn: {
name: "condition_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "product_type_id",
referencedColumnName: "id",
},
})
product_types: ProductType[]
@ManyToMany(() => ProductTag)
@JoinTable({
name: "discount_condition_product_tag",
joinColumn: {
name: "condition_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "product_tag_id",
referencedColumnName: "id",
},
})
product_tags: ProductTag[]
@ManyToMany(() => ProductCollection)
@JoinTable({
name: "discount_condition_product_collection",
joinColumn: {
name: "condition_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "product_collection_id",
referencedColumnName: "id",
},
})
product_collections: ProductCollection[]
@ManyToMany(() => CustomerGroup)
@JoinTable({
name: "discount_condition_customer_group",
joinColumn: {
name: "condition_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "customer_group_id",
referencedColumnName: "id",
},
})
customer_groups: CustomerGroup[]
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
@UpdateDateColumn({ type: resolveDbType("timestamptz") })
updated_at: Date
@DeleteDateColumn({ type: resolveDbType("timestamptz") })
deleted_at: Date
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: any
@BeforeInsert()
private beforeInsert() {
const id = ulid()
this.id = `discon_${id}`
}
}
/**
* @schema discount_condition
* title: "Discount Condition"
* description: "Holds rule conditions for when a discount is applicable"
* x-resourceId: discount_condition
* properties:
* id:
* description: "The id of the Discount Condition. Will be prefixed by `discon_`."
* type: string
* type:
* description: "The type of the Condition"
* type: string
* enum:
* - products
* - product_types
* - product_collections
* - product_tags
* - customer_groups
* created_at:
* description: "The date with timezone at which the resource was created."
* type: string
* format: date-time
* update_at:
* description: "The date with timezone at which the resource was last updated."
* type: string
* format: date-time
* deleted_at:
* description: "The date with timezone at which the resource was deleted."
* type: string
* format: date-time
* metadata:
* description: "An optional key-value map with additional information."
* type: object
*/
+12 -26
View File
@@ -1,19 +1,16 @@
import {
Entity,
BeforeInsert,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
OneToMany,
PrimaryColumn,
ManyToMany,
JoinTable,
UpdateDateColumn,
} from "typeorm"
import { ulid } from "ulid"
import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column"
import { Product } from "./product"
import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column"
import { DiscountCondition } from "./discount-condition"
export enum DiscountRuleType {
FIXED = "fixed",
@@ -50,19 +47,8 @@ export class DiscountRule {
})
allocation: AllocationType
@ManyToMany(() => Product, { cascade: true })
@JoinTable({
name: "discount_rule_products",
joinColumn: {
name: "discount_rule_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "product_id",
referencedColumnName: "id",
},
})
valid_for: Product[]
@OneToMany(() => DiscountCondition, (conditions) => conditions.discount_rule)
conditions: DiscountCondition[]
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
@@ -111,11 +97,11 @@ export class DiscountRule {
* enum:
* - total
* - item
* valid_for:
* description: "A set of Products that the discount can be used for."
* conditions:
* description: "A set of conditions that can be used to limit when the discount can be used"
* type: array
* items:
* $ref: "#/components/schemas/product"
* $ref: "#/components/schemas/discount_condition"
* created_at:
* description: "The date with timezone at which the resource was created."
* type: string
+9 -11
View File
@@ -1,21 +1,19 @@
import {
Entity,
BeforeInsert,
DeleteDateColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
Column,
PrimaryColumn,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToOne,
JoinTable,
JoinColumn,
PrimaryColumn,
UpdateDateColumn,
} from "typeorm"
import { ulid } from "ulid"
import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column"
import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column"
import { DiscountRule } from "./discount-rule"
import { Region } from "./region"
@@ -29,7 +29,6 @@ export const discounts = {
type: "percentage",
allocation: "item",
value: 10,
valid_for: [IdMap.getId("eur-8-us-10"), IdMap.getId("eur-10-us-12")],
},
regions: [IdMap.getId("region-france")],
},
@@ -50,7 +49,6 @@ export const discounts = {
type: "fixed",
allocation: "item",
value: 9,
valid_for: [IdMap.getId("eur-8-us-10"), IdMap.getId("eur-10-us-12")],
},
regions: [IdMap.getId("region-france")],
},
@@ -61,7 +59,6 @@ export const discounts = {
type: "fixed",
allocation: "item",
value: 2,
valid_for: [IdMap.getId("eur-8-us-10"), IdMap.getId("eur-10-us-12")],
},
regions: [IdMap.getId("region-france")],
},
@@ -72,7 +69,6 @@ export const discounts = {
type: "fixed",
allocation: "item",
value: 10,
valid_for: [],
},
regions: [IdMap.getId("region-france")],
},
@@ -84,7 +80,6 @@ export const discounts = {
type: "fixed",
allocation: "item",
value: 10,
valid_for: [],
},
regions: [IdMap.getId("region-france")],
},
@@ -95,7 +90,6 @@ export const discounts = {
type: "free_shipping",
allocation: "total",
value: 10,
valid_for: [],
},
regions: [IdMap.getId("region-france")],
},
@@ -106,7 +100,6 @@ export const discounts = {
type: "free_shipping",
allocation: "total",
value: 10,
valid_for: [],
},
regions: [IdMap.getId("us")],
},
@@ -122,12 +115,12 @@ export const discounts = {
}
export const DiscountModelMock = {
create: jest.fn().mockImplementation(data => Promise.resolve(data)),
create: jest.fn().mockImplementation((data) => Promise.resolve(data)),
updateOne: jest.fn().mockImplementation((query, update) => {
return Promise.resolve()
}),
deleteOne: jest.fn().mockReturnValue(Promise.resolve()),
findOne: jest.fn().mockImplementation(query => {
findOne: jest.fn().mockImplementation((query) => {
if (query._id === IdMap.getId("dynamic")) {
return Promise.resolve(discounts.dynamic)
}
@@ -0,0 +1,305 @@
import {
DeleteResult,
EntityRepository,
EntityTarget,
In,
Not,
Repository,
} from "typeorm"
import {
DiscountCondition,
DiscountConditionOperator,
DiscountConditionType,
} from "../models/discount-condition"
import { DiscountConditionCustomerGroup } from "../models/discount-condition-customer-group"
import { DiscountConditionProduct } from "../models/discount-condition-product"
import { DiscountConditionProductCollection } from "../models/discount-condition-product-collection"
import { DiscountConditionProductTag } from "../models/discount-condition-product-tag"
import { DiscountConditionProductType } from "../models/discount-condition-product-type"
export enum DiscountConditionJoinTableForeignKey {
PRODUCT_ID = "product_id",
PRODUCT_TYPE_ID = "product_type_id",
PRODUCT_COLLECTION_ID = "product_collection_id",
PRODUCT_TAG_ID = "product_tag_id",
CUSTOMER_GROUP_ID = "customer_group_id",
}
type DiscountConditionResourceType = EntityTarget<
| DiscountConditionProduct
| DiscountConditionProductType
| DiscountConditionProductCollection
| DiscountConditionProductTag
| DiscountConditionCustomerGroup
>
@EntityRepository(DiscountCondition)
export class DiscountConditionRepository extends Repository<DiscountCondition> {
getJoinTableResourceIdentifiers(type: string): {
joinTable: string
resourceKey: string
joinTableForeignKey: DiscountConditionJoinTableForeignKey
conditionTable: DiscountConditionResourceType
joinTableKey: string
} {
let conditionTable: DiscountConditionResourceType = DiscountConditionProduct
let joinTable = "product"
let joinTableForeignKey: DiscountConditionJoinTableForeignKey =
DiscountConditionJoinTableForeignKey.PRODUCT_ID
let joinTableKey = "id"
// On the joined table (e.g. `product`), what key should be match on
// (e.g `type_id` for product types and `id` for products)
let resourceKey
switch (type) {
case DiscountConditionType.PRODUCTS: {
resourceKey = "id"
joinTableForeignKey = DiscountConditionJoinTableForeignKey.PRODUCT_ID
joinTable = "product"
conditionTable = DiscountConditionProduct
break
}
case DiscountConditionType.PRODUCT_TYPES: {
resourceKey = "type_id"
joinTableForeignKey =
DiscountConditionJoinTableForeignKey.PRODUCT_TYPE_ID
joinTable = "product"
conditionTable = DiscountConditionProductType
break
}
case DiscountConditionType.PRODUCT_COLLECTIONS: {
resourceKey = "collection_id"
joinTableForeignKey =
DiscountConditionJoinTableForeignKey.PRODUCT_COLLECTION_ID
joinTable = "product"
conditionTable = DiscountConditionProductCollection
break
}
case DiscountConditionType.PRODUCT_TAGS: {
joinTableKey = "product_id"
resourceKey = "product_tag_id"
joinTableForeignKey =
DiscountConditionJoinTableForeignKey.PRODUCT_TAG_ID
joinTable = "product_tags"
conditionTable = DiscountConditionProductTag
break
}
case DiscountConditionType.CUSTOMER_GROUPS: {
joinTableKey = "customer_id"
resourceKey = "customer_group_id"
joinTable = "customer_group_customers"
joinTableForeignKey =
DiscountConditionJoinTableForeignKey.CUSTOMER_GROUP_ID
conditionTable = DiscountConditionCustomerGroup
break
}
default:
break
}
return {
joinTable,
joinTableKey,
resourceKey,
joinTableForeignKey,
conditionTable,
}
}
async removeConditionResources(
id: string,
type: DiscountConditionType,
resourceIds: string[]
): Promise<DeleteResult | void> {
const { conditionTable, joinTableForeignKey } =
this.getJoinTableResourceIdentifiers(type)
if (!conditionTable || !joinTableForeignKey) {
return Promise.resolve()
}
return await this.createQueryBuilder()
.delete()
.from(conditionTable)
.where({ condition_id: id, [joinTableForeignKey]: In(resourceIds) })
.execute()
}
async addConditionResources(
conditionId: string,
resourceIds: string[],
type: DiscountConditionType,
overrideExisting = false
): Promise<
(
| DiscountConditionProduct
| DiscountConditionProductType
| DiscountConditionProductCollection
| DiscountConditionProductTag
| DiscountConditionCustomerGroup
)[]
> {
let toInsert: { condition_id: string; [x: string]: string }[] | [] = []
const { conditionTable, joinTableForeignKey } =
this.getJoinTableResourceIdentifiers(type)
if (!conditionTable || !joinTableForeignKey) {
return Promise.resolve([])
}
toInsert = resourceIds.map((rId) => ({
condition_id: conditionId,
[joinTableForeignKey]: rId,
}))
const insertResult = await this.createQueryBuilder()
.insert()
.orIgnore(true)
.into(conditionTable)
.values(toInsert)
.execute()
if (overrideExisting) {
await this.createQueryBuilder()
.delete()
.from(conditionTable)
.where({
condition_id: conditionId,
[joinTableForeignKey]: Not(In(resourceIds)),
})
.execute()
}
return await this.manager
.createQueryBuilder(conditionTable, "discon")
.select()
.where(insertResult.identifiers)
.getMany()
}
async queryConditionTable({ type, condId, resourceId }): Promise<number> {
const {
conditionTable,
joinTable,
joinTableForeignKey,
resourceKey,
joinTableKey,
} = this.getJoinTableResourceIdentifiers(type)
return await this.manager
.createQueryBuilder(conditionTable, "dc")
.innerJoin(
joinTable,
"resource",
`dc.${joinTableForeignKey} = resource.${resourceKey} and resource.${joinTableKey} = :resourceId `,
{
resourceId,
}
)
.where(`dc.condition_id = :conditionId`, {
conditionId: condId,
})
.getCount()
}
async isValidForProduct(
discountRuleId: string,
productId: string
): Promise<boolean> {
const discountConditions = await this.createQueryBuilder("discon")
.select(["discon.id", "discon.type", "discon.operator"])
.where("discon.discount_rule_id = :discountRuleId", {
discountRuleId,
})
.getMany()
// in case of no discount conditions, we assume that the discount
// is valid for all
if (!discountConditions.length) {
return true
}
// retrieve all conditions for each type where condition type id is in jointable (products, product_types, product_collections, product_tags)
// "E.g. for a given product condition, give me all products affected by it"
// for each of these types, we check:
// if condition operation is `in` and the query for conditions defined for the given type is empty, the discount is invalid
// if condition operation is `not_in` and the query for conditions defined for the given type is not empty, the discount is invalid
for (const condition of discountConditions) {
const numConditions = await this.queryConditionTable({
type: condition.type,
condId: condition.id,
resourceId: productId,
})
if (
condition.operator === DiscountConditionOperator.IN &&
numConditions === 0
) {
return false
}
if (
condition.operator === DiscountConditionOperator.NOT_IN &&
numConditions > 0
) {
return false
}
}
return true
}
async canApplyForCustomer(
discountRuleId: string,
customerId: string
): Promise<boolean> {
const discountConditions = await this.createQueryBuilder("discon")
.select(["discon.id", "discon.type", "discon.operator"])
.where("discon.discount_rule_id = :discountRuleId", {
discountRuleId,
})
.getMany()
// in case of no discount conditions, we assume that the discount
// is valid for all
if (!discountConditions.length) {
return true
}
// retrieve conditions for customer groups
// for each customer group
// if condition operation is `in` and the query for customer group conditions is empty, the discount is invalid
// if condition operation is `not_in` and the query for customer group conditions is not empty, the discount is invalid
for (const condition of discountConditions) {
const numConditions = await this.queryConditionTable({
type: "customer_groups",
condId: condition.id,
resourceId: customerId,
})
if (
condition.operator === DiscountConditionOperator.IN &&
numConditions === 0
) {
return false
}
if (
condition.operator === DiscountConditionOperator.NOT_IN &&
numConditions > 0
) {
return false
}
}
return true
}
}
@@ -29,7 +29,6 @@ export const discounts = {
type: "percentage",
allocation: "item",
value: 10,
valid_for: [IdMap.getId("eur-8-us-10"), IdMap.getId("eur-10-us-12")],
},
regions: [IdMap.getId("region-france")],
},
@@ -50,7 +49,6 @@ export const discounts = {
type: "fixed",
allocation: "item",
value: 9,
valid_for: [IdMap.getId("eur-8-us-10"), IdMap.getId("eur-10-us-12")],
},
regions: [IdMap.getId("region-france")],
},
@@ -61,7 +59,6 @@ export const discounts = {
type: "fixed",
allocation: "item",
value: 2,
valid_for: [IdMap.getId("eur-8-us-10"), IdMap.getId("eur-10-us-12")],
},
regions: [IdMap.getId("region-france")],
},
@@ -72,7 +69,6 @@ export const discounts = {
type: "fixed",
allocation: "item",
value: 10,
valid_for: [],
},
regions: [IdMap.getId("region-france")],
},
@@ -84,7 +80,6 @@ export const discounts = {
type: "fixed",
allocation: "item",
value: 10,
valid_for: [],
},
regions: [IdMap.getId("region-france")],
},
@@ -95,7 +90,6 @@ export const discounts = {
type: "free_shipping",
allocation: "total",
value: 10,
valid_for: [],
},
regions: [IdMap.getId("region-france")],
},
@@ -106,7 +100,6 @@ export const discounts = {
type: "free_shipping",
allocation: "total",
value: 10,
valid_for: [],
},
regions: [IdMap.getId("us")],
},
@@ -125,10 +118,10 @@ export const DiscountServiceMock = {
withTransaction: function() {
return this
},
create: jest.fn().mockImplementation(data => {
create: jest.fn().mockImplementation((data) => {
return Promise.resolve(data)
}),
retrieveByCode: jest.fn().mockImplementation(data => {
retrieveByCode: jest.fn().mockImplementation((data) => {
if (data === "10%OFF") {
return Promise.resolve(discounts.total10Percent)
}
@@ -140,7 +133,7 @@ export const DiscountServiceMock = {
}
return Promise.resolve(undefined)
}),
retrieve: jest.fn().mockImplementation(data => {
retrieve: jest.fn().mockImplementation((data) => {
if (data === IdMap.getId("total10")) {
return Promise.resolve(discounts.total10Percent)
}
@@ -152,20 +145,20 @@ export const DiscountServiceMock = {
}
return Promise.resolve(undefined)
}),
update: jest.fn().mockImplementation(data => {
update: jest.fn().mockImplementation((data) => {
return Promise.resolve()
}),
delete: jest.fn().mockImplementation(data => {
delete: jest.fn().mockImplementation((data) => {
return Promise.resolve({
id: IdMap.getId("total10"),
object: "discount",
deleted: true,
})
}),
list: jest.fn().mockImplementation(data => {
list: jest.fn().mockImplementation((data) => {
return Promise.resolve([{}])
}),
listAndCount: jest.fn().mockImplementation(data => {
listAndCount: jest.fn().mockImplementation((data) => {
return Promise.resolve([{}])
}),
addRegion: jest.fn().mockReturnValue(Promise.resolve()),
+104 -36
View File
@@ -1,12 +1,12 @@
import _ from "lodash"
import { IdMap, MockRepository, MockManager } from "medusa-test-utils"
import { MedusaError } from "medusa-core-utils"
import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import CartService from "../cart"
import { InventoryServiceMock } from "../__mocks__/inventory"
import { MedusaError } from "medusa-core-utils"
const eventBusService = {
emit: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -209,7 +209,7 @@ describe("CartService", () => {
email: "email@test.com",
})
),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -317,14 +317,14 @@ describe("CartService", () => {
const lineItemService = {
update: jest.fn(),
create: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
const shippingOptionService = {
deleteShippingMethod: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -505,7 +505,7 @@ describe("CartService", () => {
const lineItemService = {
delete: jest.fn(),
update: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -547,7 +547,7 @@ describe("CartService", () => {
const shippingOptionService = {
deleteShippingMethod: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -653,7 +653,6 @@ describe("CartService", () => {
"region.countries",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"discounts.regions",
"items.tax_lines",
"region.tax_rates",
@@ -668,7 +667,7 @@ describe("CartService", () => {
describe("updateLineItem", () => {
const lineItemService = {
update: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -764,7 +763,7 @@ describe("CartService", () => {
email: data.email,
})
),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -986,7 +985,7 @@ describe("CartService", () => {
const lineItemService = {
update: jest.fn((r) => r),
delete: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -1036,7 +1035,7 @@ describe("CartService", () => {
deleteSession: jest.fn(),
updateSession: jest.fn(),
createSession: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -1254,7 +1253,7 @@ describe("CartService", () => {
deleteSession: jest.fn(),
updateSession: jest.fn(),
createSession: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -1412,7 +1411,7 @@ describe("CartService", () => {
const lineItemService = {
update: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -1425,7 +1424,7 @@ describe("CartService", () => {
})
}),
deleteShippingMethod: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -1469,11 +1468,9 @@ describe("CartService", () => {
IdMap.getId("option"),
data
)
expect(shippingOptionService.createShippingMethod).toHaveBeenCalledWith(
IdMap.getId("option"),
data,
{ cart: cart1 }
)
expect(
shippingOptionService.createShippingMethod
).toHaveBeenCalledWith(IdMap.getId("option"), data, { cart: cart1 })
})
it("successfully overrides existing profile shipping method", async () => {
@@ -1485,11 +1482,9 @@ describe("CartService", () => {
IdMap.getId("profile1"),
data
)
expect(shippingOptionService.createShippingMethod).toHaveBeenCalledWith(
IdMap.getId("profile1"),
data,
{ cart: cart2 }
)
expect(
shippingOptionService.createShippingMethod
).toHaveBeenCalledWith(IdMap.getId("profile1"), data, { cart: cart2 })
expect(shippingOptionService.deleteShippingMethod).toHaveBeenCalledWith({
id: IdMap.getId("ship1"),
shipping_option: {
@@ -1515,11 +1510,9 @@ describe("CartService", () => {
expect(shippingOptionService.createShippingMethod).toHaveBeenCalledTimes(
1
)
expect(shippingOptionService.createShippingMethod).toHaveBeenCalledWith(
IdMap.getId("additional"),
data,
{ cart: cart2 }
)
expect(
shippingOptionService.createShippingMethod
).toHaveBeenCalledWith(IdMap.getId("additional"), data, { cart: cart2 })
})
it("updates item shipping", async () => {
@@ -1539,11 +1532,9 @@ describe("CartService", () => {
expect(shippingOptionService.createShippingMethod).toHaveBeenCalledTimes(
1
)
expect(shippingOptionService.createShippingMethod).toHaveBeenCalledWith(
IdMap.getId("profile1"),
data,
{ cart: cart3 }
)
expect(
shippingOptionService.createShippingMethod
).toHaveBeenCalledWith(IdMap.getId("profile1"), data, { cart: cart3 })
expect(lineItemService.update).toHaveBeenCalledTimes(1)
expect(lineItemService.update).toHaveBeenCalledWith(IdMap.getId("line"), {
@@ -1610,6 +1601,20 @@ describe("CartService", () => {
region_id: IdMap.getId("good"),
})
}
if (q.where.id === "with-d-and-customer") {
return Promise.resolve({
id: "with-d-and-customer",
discounts: [
{
code: "ApplicableForCustomer",
rule: {
type: "fixed",
},
},
],
region_id: IdMap.getId("good"),
})
}
return Promise.resolve({
id: IdMap.getId("cart"),
discounts: [],
@@ -1718,6 +1723,19 @@ describe("CartService", () => {
ends_at: getOffsetDate(10),
})
}
if (code === "ApplicableForCustomer") {
return Promise.resolve({
id: "ApplicableForCustomer",
code: "ApplicableForCustomer",
regions: [{ id: IdMap.getId("good") }],
rule: {
id: "test-rule",
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(10),
})
}
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
@@ -1727,6 +1745,17 @@ describe("CartService", () => {
},
})
}),
canApplyForCustomer: jest
.fn()
.mockImplementation((ruleId, customerId) => {
if (ruleId === "test-rule") {
return Promise.resolve(true)
}
if (!customerId) {
return Promise.resolve(false)
}
return Promise.resolve(false)
}),
}
const cartService = new CartService({
@@ -1953,6 +1982,45 @@ describe("CartService", () => {
})
).rejects.toThrow("The discount is not available in current region")
})
it("successfully applies discount with a check for customer applicableness", async () => {
await cartService.update("with-d-and-customer", {
discounts: [
{
code: "ApplicableForCustomer",
},
],
})
expect(eventBusService.emit).toHaveBeenCalledTimes(1)
expect(eventBusService.emit).toHaveBeenCalledWith(
"cart.updated",
expect.any(Object)
)
expect(cartRepository.save).toHaveBeenCalledTimes(1)
expect(cartRepository.save).toHaveBeenCalledWith({
id: "with-d-and-customer",
region_id: IdMap.getId("good"),
discount_total: 0,
shipping_total: 0,
subtotal: 0,
tax_total: 0,
total: 0,
discounts: [
{
id: "ApplicableForCustomer",
code: "ApplicableForCustomer",
regions: [{ id: IdMap.getId("good") }],
rule: {
id: "test-rule",
type: "percentage",
},
starts_at: expect.any(Date),
ends_at: expect.any(Date),
},
],
})
})
})
describe("removeDiscount", () => {
+197 -120
View File
@@ -1,7 +1,5 @@
import DiscountService from "../discount"
import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import { MedusaError } from "medusa-core-utils"
import { exportAllDeclaration } from "@babel/types"
import DiscountService from "../discount"
describe("DiscountService", () => {
describe("create", () => {
@@ -15,7 +13,7 @@ describe("DiscountService", () => {
id: IdMap.getId("france"),
}
},
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -309,121 +307,6 @@ describe("DiscountService", () => {
})
})
describe("addValidProduct", () => {
const discountRepository = MockRepository({
findOne: () =>
Promise.resolve({
id: IdMap.getId("total10"),
rule: {
id: IdMap.getId("test-rule"),
valid_for: [{ id: IdMap.getId("test-product") }],
},
}),
})
const discountRuleRepository = MockRepository({})
const productService = {
retrieve: () => {
return {
id: IdMap.getId("test-product-2"),
}
},
}
const discountService = new DiscountService({
manager: MockManager,
discountRepository,
discountRuleRepository,
productService,
})
beforeEach(() => {
jest.clearAllMocks()
})
it("successfully adds a product", async () => {
await discountService.addValidProduct(
IdMap.getId("total10"),
IdMap.getId("test-product-2")
)
expect(discountRuleRepository.save).toHaveBeenCalledTimes(1)
expect(discountRuleRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("test-rule"),
valid_for: [
{ id: IdMap.getId("test-product") },
{ id: IdMap.getId("test-product-2") },
],
})
})
it("successfully resolves if product already exists", async () => {
await discountService.addValidProduct(
IdMap.getId("total10"),
IdMap.getId("test-product")
)
expect(discountRuleRepository.save).toHaveBeenCalledTimes(0)
})
})
describe("removeValidVariant", () => {
const discountRepository = MockRepository({
findOne: () =>
Promise.resolve({
id: IdMap.getId("total10"),
rule: {
id: IdMap.getId("test-rule"),
valid_for: [{ id: IdMap.getId("test-product") }],
},
}),
})
const discountRuleRepository = MockRepository({})
const productService = {
retrieve: () => {
return {
id: IdMap.getId("test-product"),
}
},
}
const discountService = new DiscountService({
manager: MockManager,
discountRepository,
discountRuleRepository,
productService,
})
beforeEach(() => {
jest.clearAllMocks()
})
it("successfully removes a product", async () => {
await discountService.removeValidProduct(
IdMap.getId("total10"),
IdMap.getId("test-product")
)
expect(discountRuleRepository.save).toHaveBeenCalledTimes(1)
expect(discountRuleRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("test-rule"),
valid_for: [],
})
})
it("successfully resolve if product does not exist", async () => {
await discountService.removeValidProduct(
IdMap.getId("total10"),
IdMap.getId("test-product-2")
)
expect(discountRuleRepository.save).toHaveBeenCalledTimes(0)
})
})
describe("addRegion", () => {
const discountRepository = MockRepository({
findOne: (q) => {
@@ -638,7 +521,7 @@ describe("DiscountService", () => {
expect(discountRepository.findAndCount).toHaveBeenCalledWith({
where: expect.anything(),
skip: 0,
take: 50,
take: 20,
order: { created_at: "DESC" },
})
})
@@ -658,4 +541,198 @@ describe("DiscountService", () => {
})
})
})
describe("calculateDiscountForLineItem", () => {
const discountRepository = MockRepository({
findOne: ({ where }) => {
if (where.id === "disc_percentage") {
return Promise.resolve({
code: "MEDUSA",
rule: {
type: "percentage",
allocation: "total",
value: 15,
},
})
}
if (where.id === "disc_fixed_total") {
return Promise.resolve({
code: "MEDUSA",
rule: {
type: "fixed",
allocation: "total",
value: 400,
},
})
}
return Promise.resolve({
id: "disc_fixed",
code: "MEDUSA",
rule: {
type: "fixed",
allocation: "item",
value: 200,
},
})
},
})
const totalsService = {
getSubtotal: () => {
return 1100
},
}
const discountService = new DiscountService({
manager: MockManager,
discountRepository,
totalsService,
})
beforeEach(() => {
jest.clearAllMocks()
})
it("correctly calculates fixed + item discount", async () => {
const adjustment = await discountService.calculateDiscountForLineItem(
"disc_fixed",
{
unit_price: 300,
quantity: 2,
allow_discounts: true,
}
)
expect(adjustment).toBe(400)
})
it("correctly calculates fixed + total discount", async () => {
const adjustment1 = await discountService.calculateDiscountForLineItem(
"disc_fixed_total",
{
unit_price: 400,
quantity: 2,
allow_discounts: true,
}
)
const adjustment2 = await discountService.calculateDiscountForLineItem(
"disc_fixed_total",
{
unit_price: 300,
quantity: 1,
allow_discounts: true,
}
)
expect(adjustment1).toBe(291)
expect(adjustment2).toBe(109)
})
it("returns line item amount if discount exceeds lime item price", async () => {
const adjustment = await discountService.calculateDiscountForLineItem(
"disc_fixed",
{
unit_price: 100,
quantity: 1,
allow_discounts: true,
}
)
expect(adjustment).toBe(100)
})
it("correctly calculates percentage discount", async () => {
const adjustment = await discountService.calculateDiscountForLineItem(
"disc_percentage",
{
unit_price: 400,
quantity: 2,
allow_discounts: true,
}
)
expect(adjustment).toBe(120)
})
it("returns full amount if exceeds total line item amount", async () => {
const adjustment = await discountService.calculateDiscountForLineItem(
"disc_fixed",
{
unit_price: 50,
quantity: 2,
allow_discounts: true,
}
)
expect(adjustment).toBe(100)
})
it("returns early if discounts are not allowed", async () => {
const adjustment = await discountService.calculateDiscountForLineItem(
"disc_percentage",
{
unit_price: 400,
quantity: 2,
allow_discounts: false,
}
)
expect(adjustment).toBe(0)
})
})
describe("canApplyForCustomer", () => {
const discountConditionRepository = {
canApplyForCustomer: jest
.fn()
.mockImplementation(() => Promise.resolve(true)),
}
const customerService = {
retrieve: jest.fn().mockImplementation((id) => {
if (id === "customer-no-groups") {
return Promise.resolve({ id: "customer-no-groups" })
}
if (id === "customer-with-groups") {
return Promise.resolve({
id: "customer-with-groups",
groups: [{ id: "group-1" }],
})
}
}),
}
const discountService = new DiscountService({
manager: MockManager,
discountConditionRepository,
customerService,
})
it("returns false on undefined customer id", async () => {
const res = await discountService.canApplyForCustomer("rule-1")
expect(res).toBe(false)
expect(
discountConditionRepository.canApplyForCustomer
).toHaveBeenCalledTimes(0)
})
it("returns true on customer with groups", async () => {
const res = await discountService.canApplyForCustomer(
"rule-1",
"customer-with-groups"
)
expect(res).toBe(true)
expect(
discountConditionRepository.canApplyForCustomer
).toHaveBeenCalledTimes(1)
expect(
discountConditionRepository.canApplyForCustomer
).toHaveBeenCalledWith("rule-1", "customer-with-groups")
})
})
})
+21 -20
View File
@@ -33,7 +33,7 @@ describe("OrderService", () => {
const eventBusService = {
emit: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -78,20 +78,20 @@ describe("OrderService", () => {
})
const lineItemService = {
update: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
const shippingOptionService = {
updateShippingMethod: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
const giftCardService = {
update: jest.fn(),
createTransaction: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -103,7 +103,7 @@ describe("OrderService", () => {
cancelPayment: jest.fn().mockImplementation((payment) => {
return Promise.resolve({ ...payment, status: "cancelled" })
}),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -142,7 +142,7 @@ describe("OrderService", () => {
total: 100,
})
}),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -230,7 +230,6 @@ describe("OrderService", () => {
"items",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"gift_cards",
"shipping_methods",
],
@@ -456,7 +455,7 @@ describe("OrderService", () => {
await expect(res).rejects.toThrow(
"Variant with id: variant-1 does not have the required inventory"
)
//check to see if payment is cancelled
// check to see if payment is cancelled
expect(
orderService.paymentProviderService_.cancelPayment
).toHaveBeenCalledTimes(1)
@@ -634,14 +633,14 @@ describe("OrderService", () => {
const fulfillmentService = {
cancelFulfillment: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
const paymentProviderService = {
cancelPayment: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -738,7 +737,7 @@ describe("OrderService", () => {
? Promise.reject()
: Promise.resolve({ ...p, captured_at: "notnull" })
),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -843,7 +842,7 @@ describe("OrderService", () => {
const lineItemService = {
update: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -856,7 +855,7 @@ describe("OrderService", () => {
},
])
}),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -1023,7 +1022,7 @@ describe("OrderService", () => {
})
}
}),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -1092,7 +1091,7 @@ describe("OrderService", () => {
.mockImplementation((p) =>
p.id === "payment_fail" ? Promise.reject() : Promise.resolve()
),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -1233,7 +1232,7 @@ describe("OrderService", () => {
.fn()
.mockImplementation(() => Promise.resolve({})),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -1367,7 +1366,7 @@ describe("OrderService", () => {
const lineItemService = {
update: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -1390,7 +1389,7 @@ describe("OrderService", () => {
],
})
}),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -1417,7 +1416,9 @@ describe("OrderService", () => {
)
expect(fulfillmentService.createShipment).toHaveBeenCalledTimes(1)
expect(fulfillmentService.createShipment).toHaveBeenCalledWith(
expect(
fulfillmentService.createShipment
).toHaveBeenCalledWith(
IdMap.getId("fulfillment"),
[{ tracking_number: "1234" }, { tracking_number: "2345" }],
{ metadata: undefined, no_notification: true }
@@ -1509,7 +1510,7 @@ describe("OrderService", () => {
refundPayment: jest
.fn()
.mockImplementation((p) => Promise.resolve({ id: "ref" })),
withTransaction: function () {
withTransaction: function() {
return this
},
}
+195 -196
View File
@@ -1,5 +1,5 @@
import TotalsService from "../totals"
import { IdMap } from "medusa-test-utils"
import TotalsService from "../totals"
const discounts = {
total10Percent: {
@@ -19,7 +19,6 @@ const discounts = {
type: "fixed",
allocation: "item",
value: 2,
valid_for: [{ id: "testp2" }],
},
regions: [{ id: "fr" }],
},
@@ -30,7 +29,6 @@ const discounts = {
type: "percentage",
allocation: "item",
value: 10,
valid_for: [{ id: "testp2" }],
},
regions: [{ id: "fr" }],
},
@@ -41,7 +39,6 @@ const discounts = {
type: "fixed",
allocation: "total",
value: 10,
valid_for: [],
},
regions: [{ id: "fr" }],
},
@@ -53,7 +50,6 @@ const discounts = {
type: "fixed",
allocation: "item",
value: 10,
valid_for: [],
},
regions: [{ id: "fr" }],
},
@@ -65,131 +61,130 @@ describe("TotalsService", () => {
taxCalculationStrategy: {},
}
describe("getAllocationItemDiscounts", () => {
let res
// TODO: Redo tests to include new line item adjustments
const totalsService = new TotalsService(container)
// describe("getAllocationItemDiscounts", () => {
// let res
beforeEach(() => {
jest.clearAllMocks()
})
// const totalsService = new TotalsService(container)
it("calculates item with percentage discount", async () => {
const cart = {
items: [
{
id: "test",
allow_discounts: true,
unit_price: 10,
quantity: 10,
variant: {
id: "testv",
product_id: "testp",
},
},
],
}
// beforeEach(() => {
// jest.clearAllMocks()
// })
const discount = {
rule: {
type: "percentage",
value: 10,
valid_for: [{ id: "testp" }],
},
}
// it("calculates item with percentage discount", async () => {
// const cart = {
// items: [
// {
// id: "test",
// allow_discounts: true,
// unit_price: 10,
// quantity: 10,
// variant: {
// id: "testv",
// product_id: "testp",
// },
// },
// ],
// }
res = totalsService.getAllocationItemDiscounts(discount, cart)
// const discount = {
// rule: {
// type: "percentage",
// value: 10,
// },
// }
expect(res).toEqual([
{
lineItem: {
id: "test",
allow_discounts: true,
unit_price: 10,
quantity: 10,
variant: {
id: "testv",
product_id: "testp",
},
},
variant: "testv",
amount: 10,
},
])
})
// res = totalsService.getAllocationItemDiscounts(discount, cart)
it("calculates item with fixed discount", async () => {
const cart = {
items: [
{
id: "exists",
allow_discounts: true,
unit_price: 10,
variant: {
id: "testv",
product_id: "testp",
},
quantity: 10,
},
],
}
// expect(res).toEqual([
// {
// lineItem: {
// id: "test",
// allow_discounts: true,
// unit_price: 10,
// quantity: 10,
// variant: {
// id: "testv",
// product_id: "testp",
// },
// },
// variant: "testv",
// amount: 10,
// },
// ])
// })
const discount = {
rule: {
type: "fixed",
value: 9,
valid_for: [{ id: "testp" }],
},
}
// it("calculates item with fixed discount", async () => {
// const cart = {
// items: [
// {
// id: "exists",
// allow_discounts: true,
// unit_price: 10,
// variant: {
// id: "testv",
// product_id: "testp",
// },
// quantity: 10,
// },
// ],
// }
res = totalsService.getAllocationItemDiscounts(discount, cart)
// const discount = {
// rule: {
// type: "fixed",
// value: 9,
// },
// }
expect(res).toEqual([
{
lineItem: {
id: "exists",
allow_discounts: true,
unit_price: 10,
variant: {
id: "testv",
product_id: "testp",
},
quantity: 10,
},
variant: "testv",
amount: 90,
},
])
})
// res = totalsService.getAllocationItemDiscounts(discount, cart)
it("does not apply discount if no valid variants are provided", async () => {
const cart = {
items: [
{
id: "exists",
allow_discounts: true,
unit_price: 10,
variant: {
id: "testv",
product_id: "testp",
},
quantity: 10,
},
],
}
// expect(res).toEqual([
// {
// lineItem: {
// id: "exists",
// allow_discounts: true,
// unit_price: 10,
// variant: {
// id: "testv",
// product_id: "testp",
// },
// quantity: 10,
// },
// variant: "testv",
// amount: 90,
// },
// ])
// })
const discount = {
rule: {
type: "fixed",
value: 9,
valid_for: [],
},
}
res = totalsService.getAllocationItemDiscounts(discount, cart)
// it("does not apply discount if no valid variants are provided", async () => {
// const cart = {
// items: [
// {
// id: "exists",
// allow_discounts: true,
// unit_price: 10,
// variant: {
// id: "testv",
// product_id: "testp",
// },
// quantity: 10,
// },
// ],
// }
expect(res).toEqual([])
})
})
// const discount = {
// rule: {
// type: "fixed",
// value: 9,
// },
// }
// res = totalsService.getAllocationItemDiscounts(discount, cart)
// expect(res).toEqual([])
// })
// })
describe("getDiscountTotal", () => {
let res
@@ -235,19 +230,21 @@ describe("TotalsService", () => {
expect(res).toEqual(28)
})
it("calculate item fixed discount", async () => {
discountCart.discounts.push(discounts.item2Fixed)
res = totalsService.getDiscountTotal(discountCart)
// TODO: Redo tests to include new line item adjustments
expect(res).toEqual(20)
})
// it("calculate item fixed discount", async () => {
// discountCart.discounts.push(discounts.item2Fixed)
// res = totalsService.getDiscountTotal(discountCart)
it("calculate item percentage discount", async () => {
discountCart.discounts.push(discounts.item10Percent)
res = totalsService.getDiscountTotal(discountCart)
// expect(res).toEqual(20)
// })
expect(res).toEqual(10)
})
// it("calculate item percentage discount", async () => {
// discountCart.discounts.push(discounts.item10Percent)
// res = totalsService.getDiscountTotal(discountCart)
// expect(res).toEqual(10)
// })
it("calculate total fixed discount", async () => {
discountCart.discounts.push(discounts.total10Fixed)
@@ -350,82 +347,84 @@ describe("TotalsService", () => {
expect(res).toEqual(1250)
})
it("calculates refund with total precentage discount", async () => {
orderToRefund.discounts.push(discounts.total10Percent)
res = totalsService.getRefundTotal(orderToRefund, [
{
id: "line2",
unit_price: 100,
allow_discounts: true,
variant: {
id: "variant",
product_id: "product2",
},
returned_quantity: 0,
metadata: {},
quantity: 10,
},
])
// TODO: Redo tests to include new line item adjustments
expect(res).toEqual(1125)
})
// it("calculates refund with total precentage discount", async () => {
// orderToRefund.discounts.push(discounts.total10Percent)
// res = totalsService.getRefundTotal(orderToRefund, [
// {
// id: "line2",
// unit_price: 100,
// allow_discounts: true,
// variant: {
// id: "variant",
// product_id: "product2",
// },
// returned_quantity: 0,
// metadata: {},
// quantity: 10,
// },
// ])
it("calculates refund with total fixed discount", async () => {
orderToRefund.discounts.push(discounts.total10Fixed)
res = totalsService.getRefundTotal(orderToRefund, [
{
id: "line",
unit_price: 100,
allow_discounts: true,
variant: {
id: "variant",
product_id: "product",
},
quantity: 10,
returned_quantity: 0,
},
])
// expect(res).toEqual(1125)
// })
expect(res).toEqual(1244)
})
// it("calculates refund with total fixed discount", async () => {
// orderToRefund.discounts.push(discounts.total10Fixed)
// res = totalsService.getRefundTotal(orderToRefund, [
// {
// id: "line",
// unit_price: 100,
// allow_discounts: true,
// variant: {
// id: "variant",
// product_id: "product",
// },
// quantity: 10,
// returned_quantity: 0,
// },
// ])
it("calculates refund with item fixed discount", async () => {
orderToRefund.discounts.push(discounts.item2Fixed)
res = totalsService.getRefundTotal(orderToRefund, [
{
id: "line2",
unit_price: 100,
allow_discounts: true,
variant: {
id: "variant",
product_id: "testp2",
},
quantity: 10,
returned_quantity: 0,
},
])
// expect(res).toEqual(1244)
// })
expect(res).toEqual(1225)
})
// it("calculates refund with item fixed discount", async () => {
// orderToRefund.discounts.push(discounts.item2Fixed)
// res = totalsService.getRefundTotal(orderToRefund, [
// {
// id: "line2",
// unit_price: 100,
// allow_discounts: true,
// variant: {
// id: "variant",
// product_id: "testp2",
// },
// quantity: 10,
// returned_quantity: 0,
// },
// ])
it("calculates refund with item percentage discount", async () => {
orderToRefund.discounts.push(discounts.item10Percent)
res = totalsService.getRefundTotal(orderToRefund, [
{
id: "line2",
unit_price: 100,
allow_discounts: true,
variant: {
id: "variant",
product_id: "testp2",
},
quantity: 10,
returned_quantity: 0,
},
])
// expect(res).toEqual(1225)
// })
expect(res).toEqual(1125)
})
// it("calculates refund with item percentage discount", async () => {
// orderToRefund.discounts.push(discounts.item10Percent)
// res = totalsService.getRefundTotal(orderToRefund, [
// {
// id: "line2",
// unit_price: 100,
// allow_discounts: true,
// variant: {
// id: "variant",
// product_id: "testp2",
// },
// quantity: 10,
// returned_quantity: 0,
// },
// ])
// expect(res).toEqual(1125)
// })
it("throws if line items to return is not in order", async () => {
const work = () =>
+36 -48
View File
@@ -1,48 +1,40 @@
import _ from "lodash"
import {
EntityManager,
DeepPartial,
AlreadyHasActiveConnectionError,
} from "typeorm"
import { MedusaError, Validator } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { ShippingMethodRepository } from "../repositories/shipping-method"
import { CartRepository } from "../repositories/cart"
import { AddressRepository } from "../repositories/address"
import { PaymentSessionRepository } from "../repositories/payment-session"
import { DeepPartial, EntityManager } from "typeorm"
import { IPriceSelectionStrategy } from "../interfaces/price-selection-strategy"
import { Address } from "../models/address"
import { Discount } from "../models/discount"
import { Cart } from "../models/cart"
import { CustomShippingOption } from "../models/custom-shipping-option"
import { Customer } from "../models/customer"
import { Discount } from "../models/discount"
import { LineItem } from "../models/line-item"
import { ShippingMethod } from "../models/shipping-method"
import { CustomShippingOption } from "../models/custom-shipping-option"
import { TotalField, FindConfig } from "../types/common"
import { AddressRepository } from "../repositories/address"
import { CartRepository } from "../repositories/cart"
import { PaymentSessionRepository } from "../repositories/payment-session"
import { ShippingMethodRepository } from "../repositories/shipping-method"
import {
CartCreateProps,
CartUpdateProps,
FilterableCartProps,
LineItemUpdate,
CartUpdateProps,
CartCreateProps,
} from "../types/cart"
import { FindConfig, TotalField } from "../types/common"
import CustomShippingOptionService from "./custom-shipping-option"
import CustomerService from "./customer"
import DiscountService from "./discount"
import EventBusService from "./event-bus"
import ProductVariantService from "./product-variant"
import ProductService from "./product"
import RegionService from "./region"
import GiftCardService from "./gift-card"
import InventoryService from "./inventory"
import LineItemService from "./line-item"
import PaymentProviderService from "./payment-provider"
import ProductService from "./product"
import ProductVariantService from "./product-variant"
import RegionService from "./region"
import ShippingOptionService from "./shipping-option"
import CustomerService from "./customer"
import TaxProviderService from "./tax-provider"
import DiscountService from "./discount"
import GiftCardService from "./gift-card"
import TotalsService from "./totals"
import InventoryService from "./inventory"
import CustomShippingOptionService from "./custom-shipping-option"
import { IPriceSelectionStrategy } from "../interfaces/price-selection-strategy"
type CartConstructorProps = {
manager: EntityManager
@@ -213,7 +205,6 @@ class CartService extends BaseService {
relationSet.add("gift_cards")
relationSet.add("discounts")
relationSet.add("discounts.rule")
relationSet.add("discounts.rule.valid_for")
// relationSet.add("discounts.parent_discount")
// relationSet.add("discounts.parent_discount.rule")
// relationSet.add("discounts.parent_discount.regions")
@@ -706,7 +697,6 @@ class CartService extends BaseService {
"region.countries",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"discounts.regions",
],
})
@@ -1016,10 +1006,23 @@ class CartService extends BaseService {
async applyDiscount(cart: Cart, discountCode: string): Promise<void> {
const discount = await this.discountService_.retrieveByCode(discountCode, [
"rule",
"rule.valid_for",
"regions",
])
if (cart.customer_id) {
const canApply = await this.discountService_.canApplyForCustomer(
discount.rule.id,
cart.customer_id
)
if (!canApply) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is not valid for customer"
)
}
}
const rule = discount.rule
// if limit is set and reached, we make an early exit
@@ -1103,7 +1106,7 @@ class CartService extends BaseService {
}
})
cart.discounts = newDiscounts.filter(Boolean)
cart.discounts = newDiscounts.filter(Boolean) as Discount[]
}
/**
@@ -1118,7 +1121,6 @@ class CartService extends BaseService {
relations: [
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"payment_sessions",
"shipping_methods",
],
@@ -1333,7 +1335,6 @@ class CartService extends BaseService {
"items",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"gift_cards",
"shipping_methods",
"billing_address",
@@ -1508,7 +1509,6 @@ class CartService extends BaseService {
"shipping_methods",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_methods.shipping_option",
"items",
"items.variant",
@@ -1566,12 +1566,7 @@ class CartService extends BaseService {
}
const result = await this.retrieve(cartId, {
relations: [
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_methods",
],
relations: ["discounts", "discounts.rule", "shipping_methods"],
})
// if cart has freeshipping, adjust price
@@ -1801,13 +1796,7 @@ class CartService extends BaseService {
async delete(cartId: string): Promise<string> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const cart = await this.retrieve(cartId, {
relations: [
"items",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"payment_sessions",
],
relations: ["items", "discounts", "discounts.rule", "payment_sessions"],
})
if (cart.completed_at) {
@@ -1878,7 +1867,6 @@ class CartService extends BaseService {
"gift_cards",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"shipping_methods",
"region",
"region.tax_rates",
@@ -1,22 +1,62 @@
import { parse, toSeconds } from "iso8601-duration"
import { isEmpty, omit } from "lodash"
import { MedusaError, Validator } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { Brackets, ILike } from "typeorm"
import { formatException } from "../utils/exception-formatter"
import { Brackets, EntityManager, ILike, SelectQueryBuilder } from "typeorm"
import {
EventBusService,
ProductService,
RegionService,
TotalsService,
} from "."
import { Cart } from "../models/cart"
import { Discount } from "../models/discount"
import { DiscountConditionType } from "../models/discount-condition"
import {
AllocationType as DiscountAllocation,
DiscountRule,
DiscountRuleType,
} from "../models/discount-rule"
import { LineItem } from "../models/line-item"
import { DiscountRepository } from "../repositories/discount"
import { DiscountConditionRepository } from "../repositories/discount-condition"
import { DiscountRuleRepository } from "../repositories/discount-rule"
import { GiftCardRepository } from "../repositories/gift-card"
import { FindConfig } from "../types/common"
import {
CreateDiscountInput,
CreateDynamicDiscountInput,
FilterableDiscountProps,
UpdateDiscountInput,
UpsertDiscountConditionInput,
} from "../types/discount"
import { formatException, PostgresError } from "../utils/exception-formatter"
/**
* Provides layer to manipulate discounts.
* @implements {BaseService}
*/
class DiscountService extends BaseService {
private manager_: EntityManager
private discountRepository_: typeof DiscountRepository
private discountRuleRepository_: typeof DiscountRuleRepository
private giftCardRepository_: typeof GiftCardRepository
private discountConditionRepository_: typeof DiscountConditionRepository
private totalsService_: TotalsService
private productService_: ProductService
private regionService_: RegionService
private eventBus_: EventBusService
constructor({
manager,
discountRepository,
discountRuleRepository,
giftCardRepository,
discountConditionRepository,
totalsService,
productService,
regionService,
customerService,
eventBusService,
}) {
super()
@@ -33,6 +73,9 @@ class DiscountService extends BaseService {
/** @private @const {GiftCardRepository} */
this.giftCardRepository_ = giftCardRepository
/** @private @const {DiscountConditionRepository} */
this.discountConditionRepository_ = discountConditionRepository
/** @private @const {TotalsService} */
this.totalsService_ = totalsService
@@ -42,11 +85,14 @@ class DiscountService extends BaseService {
/** @private @const {RegionService} */
this.regionService_ = regionService
/** @private @const {CustomerService} */
this.customerService_ = customerService
/** @private @const {EventBus} */
this.eventBus_ = eventBusService
}
withTransaction(transactionManager) {
withTransaction(transactionManager: EntityManager): DiscountService {
if (!transactionManager) {
return this
}
@@ -56,13 +102,16 @@ class DiscountService extends BaseService {
discountRepository: this.discountRepository_,
discountRuleRepository: this.discountRuleRepository_,
giftCardRepository: this.giftCardRepository_,
discountConditionRepository: this.discountConditionRepository_,
totalsService: this.totalsService_,
productService: this.productService_,
regionService: this.regionService_,
customerService: this.customerService_,
eventBusService: this.eventBus_,
})
cloned.transactionManager_ = transactionManager
cloned.manager_ = transactionManager
return cloned
}
@@ -72,14 +121,13 @@ class DiscountService extends BaseService {
* @param {DiscountRule} discountRule - the discount rule to create
* @return {Promise} the result of the create operation
*/
validateDiscountRule_(discountRule) {
validateDiscountRule_(discountRule): DiscountRule {
const schema = Validator.object().keys({
id: Validator.string().optional(),
description: Validator.string().optional(),
type: Validator.string().required(),
value: Validator.number().min(0).required(),
allocation: Validator.string().required(),
valid_for: Validator.array().optional(),
created_at: Validator.date().optional(),
updated_at: Validator.date().allow(null).optional(),
deleted_at: Validator.date().allow(null).optional(),
@@ -109,7 +157,10 @@ class DiscountService extends BaseService {
* @param {Object} config - the config object containing query settings
* @return {Promise} the result of the find operation
*/
async list(selector = {}, config = { relations: [], skip: 0, take: 10 }) {
async list(
selector: FilterableDiscountProps = {},
config: FindConfig<Discount> = { relations: [], skip: 0, take: 10 }
): Promise<Discount[]> {
const discountRepo = this.manager_.getCustomRepository(
this.discountRepository_
)
@@ -124,9 +175,13 @@ class DiscountService extends BaseService {
* @return {Promise} the result of the find operation
*/
async listAndCount(
selector = {},
config = { skip: 0, take: 50, order: { created_at: "DESC" } }
) {
selector: FilterableDiscountProps = {},
config: FindConfig<Discount> = {
take: 20,
skip: 0,
order: { created_at: "DESC" },
}
): Promise<[Discount[], number]> {
const discountRepo = this.manager_.getCustomRepository(
this.discountRepository_
)
@@ -144,7 +199,7 @@ class DiscountService extends BaseService {
delete where.code
query.where = (qb) => {
query.where = (qb: SelectQueryBuilder<Discount>): void => {
qb.where(where)
qb.andWhere(
@@ -166,18 +221,23 @@ class DiscountService extends BaseService {
* @param {Discount} discount - the discount data to create
* @return {Promise} the result of the create operation
*/
async create(discount) {
async create(discount: CreateDiscountInput): Promise<Discount> {
return this.atomicPhase_(async (manager) => {
const discountRepo = manager.getCustomRepository(this.discountRepository_)
const ruleRepo = manager.getCustomRepository(this.discountRuleRepository_)
if (discount.rule?.valid_for) {
discount.rule.valid_for = discount.rule.valid_for.map((id) => ({ id }))
}
const conditions = discount.rule?.conditions
const ruleToCreate = omit(discount.rule, ["conditions"])
discount.rule = ruleToCreate
const validatedRule = this.validateDiscountRule_(discount.rule)
if (discount.regions?.length > 1 && discount.rule.type === "fixed") {
if (
discount?.regions &&
discount?.regions.length > 1 &&
discount?.rule?.type === "fixed"
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Fixed discounts can have one region"
@@ -195,11 +255,18 @@ class DiscountService extends BaseService {
const discountRule = await ruleRepo.create(validatedRule)
const createdDiscountRule = await ruleRepo.save(discountRule)
discount.code = discount.code.toUpperCase()
discount.code = discount.code!.toUpperCase()
discount.rule = createdDiscountRule
const created = await discountRepo.create(discount)
const result = await discountRepo.save(created)
if (conditions?.length) {
for (const cond of conditions) {
await this.upsertDiscountCondition_(result.id, cond)
}
}
return result
} catch (error) {
throw formatException(error)
@@ -213,7 +280,10 @@ class DiscountService extends BaseService {
* @param {Object} config - the config object containing query settings
* @return {Promise<Discount>} the discount
*/
async retrieve(discountId, config = {}) {
async retrieve(
discountId: string,
config: FindConfig<Discount> = {}
): Promise<Discount> {
const discountRepo = this.manager_.getCustomRepository(
this.discountRepository_
)
@@ -238,7 +308,10 @@ class DiscountService extends BaseService {
* @param {array} relations - list of relations
* @return {Promise<Discount>} the discount document
*/
async retrieveByCode(discountCode, relations = []) {
async retrieveByCode(
discountCode: string,
relations: string[] = []
): Promise<Discount> {
const discountRepo = this.manager_.getCustomRepository(
this.discountRepository_
)
@@ -271,7 +344,10 @@ class DiscountService extends BaseService {
* @param {Discount} update - the data to update the discount with
* @return {Promise} the result of the update operation
*/
async update(discountId, update) {
async update(
discountId: string,
update: UpdateDiscountInput
): Promise<Discount> {
return this.atomicPhase_(async (manager) => {
const discountRepo = manager.getCustomRepository(this.discountRepository_)
@@ -279,10 +355,17 @@ class DiscountService extends BaseService {
relations: ["rule"],
})
const conditions = update?.rule?.conditions
const ruleToUpdate = omit(update.rule, "conditions")
if (!isEmpty(ruleToUpdate)) {
update.rule = ruleToUpdate
}
const { rule, metadata, regions, ...rest } = update
if (rest.ends_at) {
if (discount.starts_at >= new Date(update.ends_at)) {
if (discount.starts_at >= new Date(rest.ends_at)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`"ends_at" must be greater than "starts_at"`
@@ -290,13 +373,19 @@ class DiscountService extends BaseService {
}
}
if (regions?.length > 1 && discount.rule.type === "fixed") {
if (regions && regions?.length > 1 && discount.rule.type === "fixed") {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Fixed discounts can have one region"
)
}
if (conditions?.length) {
for (const cond of conditions) {
await this.upsertDiscountCondition_(discount.id, cond)
}
}
if (regions) {
discount.regions = await Promise.all(
regions.map((regionId) => this.regionService_.retrieve(regionId))
@@ -308,16 +397,11 @@ class DiscountService extends BaseService {
}
if (rule) {
discount.rule = this.validateDiscountRule_(rule)
if (rule.valid_for) {
discount.rule.valid_for = discount.rule.valid_for.map((id) => ({
id,
}))
}
discount.rule = this.validateDiscountRule_(ruleToUpdate)
}
for (const key of Object.keys(rest).filter(
(k) => rest[k] !== undefined
(k) => typeof rest[k] !== `undefined`
)) {
discount[key] = rest[key]
}
@@ -335,7 +419,10 @@ class DiscountService extends BaseService {
* @param {Object} data - the object containing a code to identify the discount by
* @return {Promise} the newly created dynamic code
*/
async createDynamicCode(discountId, data) {
async createDynamicCode(
discountId: string,
data: CreateDynamicDiscountInput
): Promise<Discount> {
return this.atomicPhase_(async (manager) => {
const discountRepo = manager.getCustomRepository(this.discountRepository_)
@@ -384,7 +471,7 @@ class DiscountService extends BaseService {
* @param {string} code - the code to identify the discount by
* @return {Promise} the newly created dynamic code
*/
async deleteDynamicCode(discountId, code) {
async deleteDynamicCode(discountId: string, code: string): Promise<void> {
return this.atomicPhase_(async (manager) => {
const discountRepo = manager.getCustomRepository(this.discountRepository_)
const discount = await discountRepo.findOne({
@@ -401,77 +488,13 @@ class DiscountService extends BaseService {
})
}
/**
* Adds a valid product to the discount rule valid_for array.
* @param {string} discountId - id of discount
* @param {string} productId - id of product to add
* @return {Promise} the result of the update operation
*/
async addValidProduct(discountId, productId) {
return this.atomicPhase_(async (manager) => {
const discountRuleRepo = manager.getCustomRepository(
this.discountRuleRepository_
)
const discount = await this.retrieve(discountId, {
relations: ["rule", "rule.valid_for"],
})
const { rule } = discount
const exists = rule.valid_for.find((p) => p.id === productId)
// If product is already present, we return early
if (exists) {
return rule
}
const product = await this.productService_.retrieve(productId)
rule.valid_for = [...rule.valid_for, product]
const updated = await discountRuleRepo.save(rule)
return updated
})
}
/**
* Removes a product from the discount rule valid_for array
* @param {string} discountId - id of discount
* @param {string} productId - id of product to add
* @return {Promise} the result of the update operation
*/
async removeValidProduct(discountId, productId) {
return this.atomicPhase_(async (manager) => {
const discountRuleRepo = manager.getCustomRepository(
this.discountRuleRepository_
)
const discount = await this.retrieve(discountId, {
relations: ["rule", "rule.valid_for"],
})
const { rule } = discount
const exists = rule.valid_for.find((p) => p.id === productId)
// If product is not present, we return early
if (!exists) {
return rule
}
rule.valid_for = rule.valid_for.filter((p) => p.id !== productId)
const updated = await discountRuleRepo.save(rule)
return updated
})
}
/**
* Adds a region to the discount regions array.
* @param {string} discountId - id of discount
* @param {string} regionId - id of region to add
* @return {Promise} the result of the update operation
*/
async addRegion(discountId, regionId) {
async addRegion(discountId: string, regionId: string): Promise<Discount> {
return this.atomicPhase_(async (manager) => {
const discountRepo = manager.getCustomRepository(this.discountRepository_)
@@ -507,7 +530,7 @@ class DiscountService extends BaseService {
* @param {string} regionId - id of region to remove
* @return {Promise} the result of the update operation
*/
async removeRegion(discountId, regionId) {
async removeRegion(discountId: string, regionId: string): Promise<Discount> {
return this.atomicPhase_(async (manager) => {
const discountRepo = manager.getCustomRepository(this.discountRepository_)
@@ -533,7 +556,7 @@ class DiscountService extends BaseService {
* @param {string} discountId - id of discount to delete
* @return {Promise} the result of the delete operation
*/
async delete(discountId) {
async delete(discountId: string): Promise<void> {
return this.atomicPhase_(async (manager) => {
const discountRepo = manager.getCustomRepository(this.discountRepository_)
@@ -549,25 +572,191 @@ class DiscountService extends BaseService {
})
}
/**
* Decorates a discount.
* @param {string} discountId - id of discount to decorate
* @param {string[]} fields - the fields to include.
* @param {string[]} expandFields - fields to expand.
* @return {Discount} return the decorated discount.
*/
async decorate(discountId, fields = [], expandFields = []) {
const requiredFields = ["id", "code", "is_dynamic", "metadata"]
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
}
}
fields = fields.concat(requiredFields)
async upsertDiscountCondition_(
discountId: string,
data: UpsertDiscountConditionInput
): Promise<void> {
const resolvedConditionType = this.resolveConditionType_(data)
const discount = await this.retrieve(discountId, {
select: fields,
relations: expandFields,
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: any) => {
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
): Promise<boolean> {
return this.atomicPhase_(async (manager) => {
const discountConditionRepo: DiscountConditionRepository =
manager.getCustomRepository(this.discountConditionRepository_)
// In case of custom line items, we don't have a product id.
// Instead of throwing, we simply invalidate the discount.
if (!productId) {
return false
}
const product = await this.productService_.retrieve(productId, {
relations: ["tags"],
})
return await discountConditionRepo.isValidForProduct(
discountRuleId,
product.id
)
})
}
// const final = await this.runDecorators_(decorated)
return discount
async calculateDiscountForLineItem(
discountId: string,
lineItem: LineItem,
cart: Cart
): Promise<number> {
let adjustment = 0
if (!lineItem.allow_discounts) {
return adjustment
}
const discount = await this.retrieve(discountId, { relations: ["rule"] })
const { type, value, allocation } = discount.rule
const fullItemPrice = lineItem.unit_price * lineItem.quantity
if (type === DiscountRuleType.PERCENTAGE) {
adjustment = Math.round((fullItemPrice / 100) * value)
} else if (
type === DiscountRuleType.FIXED &&
allocation === DiscountAllocation.TOTAL
) {
// when a fixed discount should be applied to the total,
// we create line adjustments for each item with an amount
// relative to the subtotal
const subtotal = this.totalsService_.getSubtotal(cart, {
excludeNonDiscounts: true,
})
const nominator = Math.min(value, subtotal)
const itemRelativeToSubtotal = lineItem.unit_price / subtotal
const totalItemPercentage = itemRelativeToSubtotal * lineItem.quantity
adjustment = Math.round(nominator * totalItemPercentage)
} else {
adjustment = value * lineItem.quantity
}
// if the amount of the discount exceeds the total price of the item,
// we return the total item price, else the fixed amount
return adjustment >= fullItemPrice ? fullItemPrice : adjustment
}
async canApplyForCustomer(
discountRuleId: string,
customerId: string | undefined
): Promise<boolean> {
return this.atomicPhase_(async (manager) => {
const discountConditionRepo: DiscountConditionRepository =
manager.getCustomRepository(this.discountConditionRepository_)
// Instead of throwing on missing customer id, we simply invalidate the discount
if (!customerId) {
return false
}
const customer = await this.customerService_.retrieve(customerId, {
relations: ["groups"],
})
return await discountConditionRepo.canApplyForCustomer(
discountRuleId,
customer.id
)
})
}
}
+15 -13
View File
@@ -188,8 +188,9 @@ class OrderService extends BaseService {
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
const query = this.buildQuery_(selector, config)
const { select, relations, totalsToSelect } =
this.transformQueryForTotals_(config)
const { select, relations, totalsToSelect } = this.transformQueryForTotals_(
config
)
if (select && select.length) {
query.select = select
@@ -248,8 +249,9 @@ class OrderService extends BaseService {
}
}
const { select, relations, totalsToSelect } =
this.transformQueryForTotals_(config)
const { select, relations, totalsToSelect } = this.transformQueryForTotals_(
config
)
if (select && select.length) {
query.select = select
@@ -306,7 +308,6 @@ class OrderService extends BaseService {
relationSet.add("claims.additional_items.tax_lines")
relationSet.add("discounts")
relationSet.add("discounts.rule")
relationSet.add("discounts.rule.valid_for")
relationSet.add("gift_cards")
relationSet.add("gift_card_transactions")
relationSet.add("refunds")
@@ -340,8 +341,9 @@ class OrderService extends BaseService {
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
const validatedId = this.validateId_(orderId)
const { select, relations, totalsToSelect } =
this.transformQueryForTotals_(config)
const { select, relations, totalsToSelect } = this.transformQueryForTotals_(
config
)
const query = {
where: { id: validatedId },
@@ -377,8 +379,9 @@ class OrderService extends BaseService {
async retrieveByCartId(cartId, config = {}) {
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
const { select, relations, totalsToSelect } =
this.transformQueryForTotals_(config)
const { select, relations, totalsToSelect } = this.transformQueryForTotals_(
config
)
const query = {
where: { cart_id: cartId },
@@ -414,8 +417,9 @@ class OrderService extends BaseService {
async retrieveByExternalId(externalId, config = {}) {
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
const { select, relations, totalsToSelect } =
this.transformQueryForTotals_(config)
const { select, relations, totalsToSelect } = this.transformQueryForTotals_(
config
)
const query = {
where: { external_id: externalId },
@@ -508,7 +512,6 @@ class OrderService extends BaseService {
"items",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"gift_cards",
"shipping_methods",
],
@@ -1180,7 +1183,6 @@ class OrderService extends BaseService {
relations: [
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"region",
"fulfillments",
"shipping_address",
+6 -5
View File
@@ -422,8 +422,9 @@ class ReturnService extends BaseService {
}
)
const calculationContext =
this.totalsService_.getCalculationContext(order)
const calculationContext = this.totalsService_.getCalculationContext(
order
)
const taxLines = await this.taxProviderService_
.withTransaction(manager)
@@ -495,8 +496,9 @@ class ReturnService extends BaseService {
return returnOrder
}
const fulfillmentData =
await this.fulfillmentProviderService_.createReturn(returnData)
const fulfillmentData = await this.fulfillmentProviderService_.createReturn(
returnData
)
returnOrder.shipping_data = fulfillmentData
@@ -558,7 +560,6 @@ class ReturnService extends BaseService {
"payments",
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"refunds",
"shipping_methods",
"region",
+5 -5
View File
@@ -1,10 +1,10 @@
import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { EntityManager } from "typeorm"
import { MedusaError } from "medusa-core-utils"
import { TaxRate } from "../models/tax-rate"
import { ShippingTaxRate } from "../models/shipping-tax-rate"
import { ProductTaxRate } from "../models/product-tax-rate"
import { ProductTypeTaxRate } from "../models/product-type-tax-rate"
import { ShippingTaxRate } from "../models/shipping-tax-rate"
import { TaxRate } from "../models/tax-rate"
import { TaxRateRepository } from "../repositories/tax-rate"
import ProductService from "../services/product"
import ProductTypeService from "../services/product-type"
@@ -12,9 +12,9 @@ import ShippingOptionService from "../services/shipping-option"
import { FindConfig } from "../types/common"
import {
CreateTaxRateInput,
UpdateTaxRateInput,
TaxRateListByConfig,
FilterableTaxRateProps,
TaxRateListByConfig,
UpdateTaxRateInput,
} from "../types/tax-rate"
class TaxRateService extends BaseService {
+13 -32
View File
@@ -1,28 +1,25 @@
import _ from "lodash"
import { BaseService } from "medusa-interfaces"
import { MedusaError } from "medusa-core-utils"
import { LineItemTaxLine } from "../models/line-item-tax-line"
import { ShippingMethodTaxLine } from "../models/shipping-method-tax-line"
import { Order } from "../models/order"
import { Cart } from "../models/cart"
import { ShippingMethod } from "../models/shipping-method"
import { LineItem } from "../models/line-item"
import { Discount } from "../models/discount"
import { DiscountRuleType } from "../models/discount-rule"
import TaxProviderService from "./tax-provider"
import { BaseService } from "medusa-interfaces"
import { ITaxCalculationStrategy } from "../interfaces/tax-calculation-strategy"
import { TaxCalculationContext } from "../interfaces/tax-service"
import { Cart } from "../models/cart"
import { Discount } from "../models/discount"
import { DiscountRuleType } from "../models/discount-rule"
import { LineItem } from "../models/line-item"
import { LineItemTaxLine } from "../models/line-item-tax-line"
import { Order } from "../models/order"
import { ShippingMethod } from "../models/shipping-method"
import { ShippingMethodTaxLine } from "../models/shipping-method-tax-line"
import { isCart } from "../types/cart"
import { isOrder } from "../types/orders"
import {
SubtotalOptions,
LineDiscount,
LineAllocationsMap,
LineDiscount,
LineDiscountAmount,
SubtotalOptions,
} from "../types/totals"
import TaxProviderService from "./tax-provider"
type ShippingMethodTotals = {
price: number
@@ -594,23 +591,7 @@ class TotalsService extends BaseService {
cart: Cart | Order
): LineDiscount[] {
const discounts: LineDiscount[] = []
for (const item of cart.items) {
if (discount.rule.valid_for?.length > 0) {
discount.rule.valid_for.map(({ id }) => {
if (item.variant.product_id === id) {
discounts.push(
this.calculateDiscount_(
item,
item.variant.id,
item.unit_price,
discount.rule.value,
discount.rule.type
)
)
}
})
}
}
// TODO: Add line item adjustments
return discounts
}
+6 -12
View File
@@ -29,20 +29,14 @@ class OrderSubscriber {
this.eventBus_.subscribe("order.placed", this.updateDraftOrder)
}
handleOrderPlaced = async data => {
handleOrderPlaced = async (data) => {
const order = await this.orderService_.retrieve(data.id, {
select: ["subtotal"],
relations: [
"discounts",
"discounts.rule",
"discounts.rule.valid_for",
"items",
"gift_cards",
],
relations: ["discounts", "discounts.rule", "items", "gift_cards"],
})
await Promise.all(
order.items.map(async i => {
order.items.map(async (i) => {
if (i.is_giftcard) {
for (let qty = 0; qty < i.quantity; qty++) {
await this.giftCardService_.create({
@@ -58,7 +52,7 @@ class OrderSubscriber {
)
await Promise.all(
order.discounts.map(async d => {
order.discounts.map(async (d) => {
const usageCount = d?.usage_count || 0
return this.discountService_.update(d.id, {
usage_count: usageCount + 1,
@@ -67,11 +61,11 @@ class OrderSubscriber {
)
}
updateDraftOrder = async data => {
updateDraftOrder = async (data) => {
const order = await this.orderService_.retrieve(data.id)
const draftOrder = await this.draftOrderService_
.retrieveByCartId(order.cart_id)
.catch(_ => null)
.catch((_) => null)
if (draftOrder) {
await this.draftOrderService_.registerCartCompletion(
+1 -1
View File
@@ -22,7 +22,7 @@ export interface FindConfig<Entity> {
skip?: number
take?: number
relations?: string[]
order?: { [k: symbol]: "ASC" | "DESC" }
order?: Record<string, "ASC" | "DESC">
}
export type PaginatedResponse = { limit: number; offset: number; count: number }
+155 -2
View File
@@ -1,12 +1,165 @@
import { IsEnum, IsOptional } from "class-validator"
import { Transform, Type } from "class-transformer"
import {
IsArray,
IsBoolean,
IsEnum,
IsOptional,
IsString,
Validate,
ValidateNested,
} from "class-validator"
import { DiscountConditionOperator } from "../models/discount-condition"
import { AllocationType, DiscountRuleType } from "../models/discount-rule"
import { ExactlyOne } from "./validators/exactly-one"
export type QuerySelector = {
q?: string
}
export class ListSelector {
export class FilterableDiscountProps {
@IsString()
@IsOptional()
q?: string
@IsBoolean()
@IsOptional()
@Transform(({ value }) => value === "true")
is_dynamic?: boolean
@IsBoolean()
@IsOptional()
@Transform(({ value }) => value === "true")
is_disabled?: boolean
@ValidateNested()
@IsOptional()
@Type(() => AdminGetDiscountsDiscountRuleParams)
rule?: AdminGetDiscountsDiscountRuleParams
}
export class AdminGetDiscountsDiscountRuleParams {
@IsOptional()
@IsEnum(DiscountRuleType)
type?: DiscountRuleType
@IsOptional()
@IsEnum(AllocationType)
allocation?: AllocationType
}
export class AdminUpsertConditionsReq {
@Validate(ExactlyOne, [
"product_collections",
"product_types",
"product_tags",
"customer_groups",
])
@IsArray()
@IsOptional()
@IsString({ each: true })
products?: string[]
@Validate(ExactlyOne, [
"products",
"product_types",
"product_tags",
"customer_groups",
])
@IsArray()
@IsOptional()
@IsString({ each: true })
product_collections?: string[]
@Validate(ExactlyOne, [
"product_collections",
"products",
"product_tags",
"customer_groups",
])
@IsArray()
@IsOptional()
@IsString({ each: true })
product_types?: string[]
@Validate(ExactlyOne, [
"product_collections",
"product_types",
"products",
"customer_groups",
])
@IsArray()
@IsOptional()
@IsString({ each: true })
product_tags?: string[]
@Validate(ExactlyOne, [
"product_collections",
"product_types",
"products",
"product_tags",
])
@IsArray()
@IsOptional()
@IsString({ each: true })
customer_groups?: string[]
}
export type UpsertDiscountConditionInput = {
id?: string
operator?: DiscountConditionOperator
products?: string[]
product_collections?: string[]
product_types?: string[]
product_tags?: string[]
customer_groups?: string[]
}
export type CreateDiscountRuleInput = {
description?: string
type: string
value: number
allocation: string
conditions?: UpsertDiscountConditionInput[]
}
export type CreateDiscountInput = {
code: string
rule: CreateDiscountRuleInput
is_dynamic: boolean
is_disabled: boolean
starts_at?: Date
ends_at?: Date
valid_duration?: string
usage_limit?: number
regions?: string[]
metadata?: Record<string, unknown>
}
export type UpdateDiscountRuleInput = {
id: string
description?: string
type: string
value: number
allocation: string
conditions?: UpsertDiscountConditionInput[]
}
export type UpdateDiscountInput = {
code?: string
rule?: UpdateDiscountRuleInput
is_dynamic?: boolean
is_disabled?: boolean
starts_at?: Date
ends_at?: Date
valid_duration?: string
usage_limit?: number
regions?: string[]
metadata?: Record<string, unknown>
}
export type CreateDynamicDiscountInput = {
code: string
ends_at?: Date
usage_limit: number
metadata?: object
}
@@ -0,0 +1,31 @@
import {
isDefined,
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from "class-validator"
// Defines constraint that ensures exactly one of given properties
// It simply checks if any of the values provided is defined as
// a property on the class along side the property which is decorated
//
// Inspiration: https://github.com/typestack/class-validator/issues/245
@ValidatorConstraint({ async: false })
export class ExactlyOne implements ValidatorConstraintInterface {
validate(propertyValue: string, args: ValidationArguments): boolean {
if (isDefined(propertyValue)) {
return this.getFailedConstraints(args).length === 0
}
return true
}
defaultMessage(args: ValidationArguments): string {
return `Only one of ${args.property}, ${this.getFailedConstraints(
args
).join(", ")} is allowed`
}
getFailedConstraints(args: ValidationArguments): boolean[] {
return args.constraints.filter((prop) => isDefined(args.object[prop]))
}
}
@@ -0,0 +1,83 @@
import { pick } from "lodash"
import { FindConfig } from "../types/common"
type BaseEntity = {
id: string
created_at: Date
}
export function pickByConfig<TModel extends BaseEntity>(
obj: TModel | TModel[],
config: FindConfig<TModel>
): Partial<TModel> | Partial<TModel>[] {
const fields = [...(config.select ?? []), ...(config.relations ?? [])]
if (fields.length) {
if (Array.isArray(obj)) {
return obj.map((o) => pick(o, fields))
} else {
return pick(obj, fields)
}
}
return obj
}
export function getRetrieveConfig<TModel extends BaseEntity>(
defaultFields: (keyof TModel)[],
defaultRelations: string[],
fields?: (keyof TModel)[],
expand?: string[]
): FindConfig<TModel> {
let includeFields: (keyof TModel)[] = []
if (typeof fields !== "undefined") {
const fieldSet = new Set(fields)
fieldSet.add("id")
includeFields = Array.from(fieldSet) as (keyof TModel)[]
}
let expandFields: string[] = []
if (typeof expand !== "undefined") {
expandFields = expand
}
return {
select: includeFields.length ? includeFields : defaultFields,
relations: expandFields.length ? expandFields : defaultRelations,
}
}
export function getListConfig<TModel extends BaseEntity>(
defaultFields: (keyof TModel)[],
defaultRelations: string[],
fields?: (keyof TModel)[],
expand?: string[],
limit = 50,
offset = 0,
order?: { [k: symbol]: "DESC" | "ASC" }
): FindConfig<TModel> {
let includeFields: (keyof TModel)[] = []
if (typeof fields !== "undefined") {
const fieldSet = new Set(fields)
// Ensure created_at is included, since we are sorting on this
fieldSet.add("created_at")
fieldSet.add("id")
includeFields = Array.from(fieldSet) as (keyof TModel)[]
}
let expandFields: string[] = []
if (typeof expand !== "undefined") {
expandFields = expand
}
const orderBy: Record<string, "DESC" | "ASC"> = order ?? {
created_at: "DESC",
}
return {
select: includeFields.length ? includeFields : defaultFields,
relations: expandFields.length ? expandFields : defaultRelations,
skip: offset,
take: limit,
order: orderBy,
}
}