Feat:discount expiration date (#403)

* discount expiration validation and testing

* integration testing

* double quotes

* add iso8601 package

* api testing

* add joi validation of start and end dates as well as valid_duration

* valid_duration column

* service testing

* discount validation in services

* integration test with invalid date interval

* include valid_duration when fetching a discount

* rename variable for clarity

* add test for dynamic discount with expiration date

* remove debug code

* adjust tests to reflect valid_duration being included in default fields

* additional discount update validation

* fixed failing test

* set ends_at on dynamic discount creation

* discount integration tests

* removed unused console.log

* removed validation of dynamic discounts by duration and added ends_at to dynamic discount creation

* integration tests for dynamic discount with and without duration

* optional valid duration for dynamic discounts

* allow nullable dynamic discount durations

* expect assertions

* fix unit test after change to dynamic discounts without duration

* change to date instead of string

* add assertions

* error handling

* addressed feedback
This commit is contained in:
pKorsholm
2021-09-30 12:13:59 +02:00
committed by GitHub
parent ae0ab03fac
commit 9b64828ec3
22 changed files with 1009 additions and 21 deletions

View File

@@ -158,6 +158,193 @@ describe("/admin/discounts", () => {
})
)
})
it("creates a discount with start and end dates", async () => {
const api = useApi()
const response = await api
.post(
"/admin/discounts",
{
code: "HELLOWORLD",
rule: {
description: "test",
type: "percentage",
value: 10,
allocation: "total",
},
usage_limit: 10,
starts_at: new Date("09/15/2021 11:50"),
ends_at: new Date("09/15/2021 17:50"),
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
expect(response.status).toEqual(200)
expect(response.data.discount).toEqual(
expect.objectContaining({
code: "HELLOWORLD",
usage_limit: 10,
starts_at: expect.any(String),
ends_at: expect.any(String),
})
)
expect(new Date(response.data.discount.starts_at)).toEqual(
new Date("09/15/2021 11:50")
)
expect(new Date(response.data.discount.ends_at)).toEqual(
new Date("09/15/2021 17:50")
)
const updated = await api
.post(
`/admin/discounts/${response.data.discount.id}`,
{
usage_limit: 20,
starts_at: new Date("09/14/2021 11:50"),
ends_at: new Date("09/17/2021 17:50"),
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
expect(updated.status).toEqual(200)
expect(updated.data.discount).toEqual(
expect.objectContaining({
code: "HELLOWORLD",
usage_limit: 20,
starts_at: expect.any(String),
ends_at: expect.any(String),
})
)
expect(new Date(updated.data.discount.starts_at)).toEqual(
new Date("09/14/2021 11:50")
)
expect(new Date(updated.data.discount.ends_at)).toEqual(
new Date("09/17/2021 17:50")
)
})
it("fails to update end date to a date before start date", async () => {
expect.assertions(6)
const api = useApi()
const response = await api
.post(
"/admin/discounts",
{
code: "HELLOWORLD",
rule: {
description: "test",
type: "percentage",
value: 10,
allocation: "total",
},
usage_limit: 10,
starts_at: new Date("09/15/2021 11:50"),
ends_at: new Date("09/15/2021 17:50"),
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err)
})
expect(response.status).toEqual(200)
expect(response.data.discount).toEqual(
expect.objectContaining({
code: "HELLOWORLD",
usage_limit: 10,
starts_at: expect.any(String),
ends_at: expect.any(String),
})
)
expect(new Date(response.data.discount.starts_at)).toEqual(
new Date("09/15/2021 11:50")
)
expect(new Date(response.data.discount.ends_at)).toEqual(
new Date("09/15/2021 17:50")
)
await api
.post(
`/admin/discounts/${response.data.discount.id}`,
{
usage_limit: 20,
ends_at: new Date("09/11/2021 17:50"),
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual(
`"ends_at" must be greater than "starts_at"`
)
})
})
it("fails to create discount with end date before start date", async () => {
expect.assertions(2)
const api = useApi()
const response = await api
.post(
"/admin/discounts",
{
code: "HELLOWORLD",
rule: {
description: "test",
type: "percentage",
value: 10,
allocation: "total",
},
usage_limit: 10,
starts_at: new Date("09/15/2021 11:50"),
ends_at: new Date("09/14/2021 17:50"),
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual([
expect.objectContaining({
message: `"ends_at" must be greater than "ref:starts_at"`,
}),
])
})
})
})
describe("testing for soft-deletion + uniqueness on discount codes", () => {
@@ -286,6 +473,21 @@ describe("/admin/discounts", () => {
is_dynamic: true,
is_disabled: false,
rule_id: "test-discount-rule",
valid_duration: "P2Y",
})
await manager.insert(DiscountRule, {
id: "test-discount-rule1",
description: "Dynamic rule",
type: "percentage",
value: 10,
allocation: "total",
})
await manager.insert(Discount, {
id: "test-discount1",
code: "DYNAMICCode",
is_dynamic: true,
is_disabled: false,
rule_id: "test-discount-rule1",
})
} catch (err) {
console.log(err)
@@ -298,7 +500,7 @@ describe("/admin/discounts", () => {
await db.teardown()
})
it("creates a dynamic discount", async () => {
it("creates a dynamic discount with ends_at", async () => {
const api = useApi()
const response = await api
@@ -318,6 +520,40 @@ describe("/admin/discounts", () => {
})
expect(response.status).toEqual(200)
expect(response.data.discount).toEqual(
expect.objectContaining({
code: "HELLOWORLD",
ends_at: expect.any(String),
})
)
})
it("creates a dynamic discount without ends_at", async () => {
const api = useApi()
const response = await api
.post(
"/admin/discounts/test-discount1/dynamic-codes",
{
code: "HELLOWORLD",
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
// console.log(err)
})
expect(response.status).toEqual(200)
expect(response.data.discount).toEqual(
expect.objectContaining({
code: "HELLOWORLD",
ends_at: null,
})
)
})
})
})

View File

@@ -131,6 +131,7 @@ describe("/store/carts", () => {
})
it("fails on apply discount if limit has been reached", async () => {
expect.assertions(2)
const api = useApi()
try {
@@ -145,6 +146,62 @@ describe("/store/carts", () => {
}
})
it("fails to apply expired discount", async () => {
expect.assertions(2)
const api = useApi()
try {
await api.post("/store/carts/test-cart", {
discounts: [{ code: "EXP_DISC" }],
})
} catch (error) {
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual("Discount is expired")
}
})
it("fails on discount before start day", async () => {
expect.assertions(2)
const api = useApi()
try {
await api.post("/store/carts/test-cart", {
discounts: [{ code: "PREM_DISC" }],
})
} catch (error) {
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual("Discount is not valid yet")
}
})
it("fails on apply invalid dynamic discount", async () => {
const api = useApi()
try {
await api.post("/store/carts/test-cart", {
discounts: [{ code: "INV_DYN_DISC" }],
})
} catch (error) {
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual("Discount is expired")
}
})
it("Applies dynamic discount to cart correctly", async () => {
const api = useApi()
const cart = await api.post(
"/store/carts/test-cart",
{
discounts: [{ code: "DYN_DISC" }],
},
{ withCredentials: true }
)
expect(cart.data.cart.shipping_total).toBe(1000)
expect(cart.status).toEqual(200)
})
it("updates cart customer id", async () => {
const api = useApi()
@@ -425,13 +482,15 @@ describe("/store/carts", () => {
)
// Add a 10% discount to the cart
const cartWithGiftcard = await api.post(
"/store/carts/test-cart",
{
discounts: [{ code: "10PERCENT" }],
},
{ withCredentials: true }
)
const cartWithGiftcard = await api
.post(
"/store/carts/test-cart",
{
discounts: [{ code: "10PERCENT" }],
},
{ withCredentials: true }
)
.catch((err) => console.log(err))
// Ensure that the discount is only applied to the standard item
expect(cartWithGiftcard.data.cart.total).toBe(1900) // 1000 (giftcard) + 900 (standard item with 10% discount)

View File

@@ -17,6 +17,18 @@ const {
} = require("@medusajs/medusa")
module.exports = async (connection, data = {}) => {
const yesterday = ((today) => new Date(today.setDate(today.getDate() - 1)))(
new Date()
)
const tomorrow = ((today) => new Date(today.setDate(today.getDate() + 1)))(
new Date()
)
const tenDaysAgo = ((today) => new Date(today.setDate(today.getDate() - 10)))(
new Date()
)
const tenDaysFromToday = ((today) =>
new Date(today.setDate(today.getDate() + 10)))(new Date())
const manager = connection.manager
const defaultProfile = await manager.findOne(ShippingProfile, {
@@ -88,6 +100,8 @@ module.exports = async (connection, data = {}) => {
code: "10PERCENT",
is_dynamic: false,
is_disabled: false,
starts_at: tenDaysAgo,
ends_at: tenDaysFromToday,
})
tenPercent.regions = [r]
@@ -114,6 +128,92 @@ module.exports = async (connection, data = {}) => {
await manager.save(d)
const expiredRule = manager.create(DiscountRule, {
id: "expiredRule",
description: "expired rule",
type: "fixed",
value: 100,
allocation: "total",
})
const expiredDisc = manager.create(Discount, {
id: "expiredDisc",
code: "EXP_DISC",
is_dynamic: false,
is_disabled: false,
starts_at: tenDaysAgo,
ends_at: yesterday,
})
expiredDisc.regions = [r]
expiredDisc.rule = expiredRule
await manager.save(expiredDisc)
const prematureRule = manager.create(DiscountRule, {
id: "prematureRule",
description: "premature rule",
type: "fixed",
value: 100,
allocation: "total",
})
const prematureDiscount = manager.create(Discount, {
id: "prematureDiscount",
code: "PREM_DISC",
is_dynamic: false,
is_disabled: false,
starts_at: tomorrow,
ends_at: tenDaysFromToday,
})
prematureDiscount.regions = [r]
prematureDiscount.rule = prematureRule
await manager.save(prematureDiscount)
const invalidDynamicRule = manager.create(DiscountRule, {
id: "invalidDynamicRule",
description: "invalidDynamic rule",
type: "fixed",
value: 100,
allocation: "total",
})
const invalidDynamicDiscount = manager.create(Discount, {
id: "invalidDynamicDiscount",
code: "INV_DYN_DISC",
is_dynamic: true,
is_disabled: false,
starts_at: tenDaysAgo,
ends_at: tenDaysFromToday,
valid_duration: "P1D", // one day
})
invalidDynamicDiscount.regions = [r]
invalidDynamicDiscount.rule = invalidDynamicRule
await manager.save(invalidDynamicDiscount)
const DynamicRule = manager.create(DiscountRule, {
id: "DynamicRule",
description: "Dynamic rule",
type: "fixed",
value: 10000,
allocation: "total",
})
const DynamicDiscount = manager.create(Discount, {
id: "DynamicDiscount",
code: "DYN_DISC",
is_dynamic: true,
is_disabled: false,
starts_at: tenDaysAgo,
ends_at: tenDaysFromToday,
valid_duration: "P1M", //one month
})
DynamicDiscount.regions = [r]
DynamicDiscount.rule = DynamicRule
await manager.save(DynamicDiscount)
await manager.query(
`UPDATE "country" SET region_id='test-region' WHERE iso_2 = 'us'`
)
@@ -304,6 +404,11 @@ module.exports = async (connection, data = {}) => {
data: {},
})
await manager.save(pay)
cart2.payment = pay
await manager.save(cart2)
const swapPay = manager.create(Payment, {
id: "test-swap-payment",
amount: 10000,

View File

@@ -1,16 +1,16 @@
const path = require('path');
const {dropDatabase} = require('pg-god');
const path = require("path")
const { dropDatabase } = require("pg-god")
require('dotenv').config({path: path.join(__dirname, '.env')});
require("dotenv").config({ path: path.join(__dirname, ".env") })
const DB_USERNAME = process.env.DB_USERNAME || 'postgres';
const DB_PASSWORD = process.env.DB_PASSWORD || '';
const DB_USERNAME = process.env.DB_USERNAME || "postgres"
const DB_PASSWORD = process.env.DB_PASSWORD || ""
const pgGodCredentials = {
user: DB_USERNAME,
password: DB_PASSWORD,
};
}
afterAll(() => {
dropDatabase({databaseName: 'medusa-integration'}, pgGodCredentials);
});
dropDatabase({ databaseName: "medusa-integration" }, pgGodCredentials)
})

View File

@@ -63,6 +63,7 @@
"glob": "^7.1.6",
"ioredis": "^4.17.3",
"ioredis-mock": "^5.6.0",
"iso8601-duration": "^1.3.0",
"joi": "^17.3.0",
"joi-objectid": "^3.0.1",
"jsonwebtoken": "^8.5.1",

View File

@@ -46,6 +46,7 @@ describe("POST /admin/discounts/:discount_id/regions/:region_id", () => {
"updated_at",
"deleted_at",
"metadata",
"valid_duration",
],
relations: ["rule", "parent_discount", "regions", "rule.valid_for"],
}

View File

@@ -46,6 +46,7 @@ describe("POST /admin/discounts/:discount_id/variants/:variant_id", () => {
"updated_at",
"deleted_at",
"metadata",
"valid_duration",
],
relations: ["rule", "parent_discount", "regions", "rule.valid_for"],
}

View File

@@ -16,6 +16,8 @@ describe("POST /admin/discounts", () => {
value: 10,
allocation: "total",
},
starts_at: "02/02/2021 13:45",
ends_at: "03/14/2021 04:30",
},
adminSession: {
jwt: {
@@ -39,12 +41,99 @@ describe("POST /admin/discounts", () => {
value: 10,
allocation: "total",
},
starts_at: new Date("02/02/2021 13:45"),
ends_at: new Date("03/14/2021 04:30"),
is_disabled: false,
is_dynamic: false,
})
})
})
describe("unsuccessful creation with dynamic discount using an invalid iso8601 duration", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request("POST", "/admin/discounts", {
payload: {
code: "TEST",
rule: {
description: "Test",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: "02/02/2021 13:45",
is_dynamic: true,
valid_duration: "PaMT2D",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns error", () => {
expect(subject.body.message[0].message).toEqual(
`"valid_duration" must be a valid ISO 8601 duration`
)
})
})
describe("successful creation with dynamic discount", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request("POST", "/admin/discounts", {
payload: {
code: "TEST",
rule: {
description: "Test",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: "02/02/2021 13:45",
is_dynamic: true,
valid_duration: "P1Y2M03DT04H05M",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls service create", () => {
expect(DiscountServiceMock.create).toHaveBeenCalledTimes(1)
expect(DiscountServiceMock.create).toHaveBeenCalledWith({
code: "TEST",
rule: {
description: "Test",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: new Date("02/02/2021 13:45"),
is_disabled: false,
is_dynamic: true,
valid_duration: "P1Y2M03DT04H05M",
})
})
})
describe("fails on invalid data", () => {
let subject
@@ -74,4 +163,84 @@ describe("POST /admin/discounts", () => {
expect(subject.body.message[0].message).toEqual(`"rule.type" is required`)
})
})
describe("fails on invalid date intervals", () => {
let subject
beforeAll(async () => {
subject = await request("POST", "/admin/discounts", {
payload: {
code: "TEST",
rule: {
description: "Test",
type: "fixed",
value: 10,
allocation: "total",
},
ends_at: "02/02/2021",
starts_at: "03/14/2021",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns error", () => {
expect(subject.body.message[0].message).toEqual(
`"ends_at" must be greater than "ref:starts_at"`
)
})
})
describe("succesfully creates a dynamic discount without setting valid duration", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request("POST", "/admin/discounts", {
payload: {
code: "TEST",
is_dynamic: true,
rule: {
description: "Test",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: "03/14/2021 14:30",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns error", () => {
expect(DiscountServiceMock.create).toHaveBeenCalledWith({
code: "TEST",
is_dynamic: true,
is_disabled: false,
rule: {
description: "Test",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: new Date("03/14/2021 14:30"),
})
})
})
})

View File

@@ -17,6 +17,7 @@ const defaultFields = [
"updated_at",
"deleted_at",
"metadata",
"valid_duration",
]
const defaultRelations = [

View File

@@ -17,6 +17,7 @@ const defaultFields = [
"updated_at",
"deleted_at",
"metadata",
"valid_duration",
]
const defaultRelations = [

View File

@@ -17,6 +17,7 @@ const defaultFields = [
"updated_at",
"deleted_at",
"metadata",
"valid_duration",
]
const defaultRelations = [

View File

@@ -7,6 +7,7 @@ describe("POST /admin/discounts", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request(
"POST",
`/admin/discounts/${IdMap.getId("total10")}`,
@@ -50,4 +51,139 @@ describe("POST /admin/discounts", () => {
)
})
})
describe("unsuccessful update with dynamic discount using an invalid iso8601 duration", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request(
"POST",
`/admin/discounts/${IdMap.getId("total10")}`,
{
payload: {
code: "10TOTALOFF",
rule: {
id: "1234",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: "02/02/2021 13:45",
is_dynamic: true,
valid_duration: "PaMT2D",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns error", () => {
expect(subject.body.message[0].message).toEqual(
`"valid_duration" must be a valid ISO 8601 duration`
)
})
})
describe("successful update with dynamic discount", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request(
"POST",
`/admin/discounts/${IdMap.getId("total10")}`,
{
payload: {
code: "10TOTALOFF",
rule: {
id: "1234",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: "02/02/2021 13:45",
is_dynamic: true,
valid_duration: "P1Y2M03DT04H05M",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls service update", () => {
expect(DiscountServiceMock.update).toHaveBeenCalledTimes(1)
expect(DiscountServiceMock.update).toHaveBeenCalledWith(
IdMap.getId("total10"),
{
code: "10TOTALOFF",
rule: {
id: "1234",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: new Date("02/02/2021 13:45"),
is_dynamic: true,
valid_duration: "P1Y2M03DT04H05M",
}
)
})
})
describe("fails on invalid date intervals", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request(
"POST",
`/admin/discounts/${IdMap.getId("total10")}`,
{
payload: {
code: "10TOTALOFF",
rule: {
id: "1234",
type: "fixed",
value: 10,
allocation: "total",
},
ends_at: "02/02/2021",
starts_at: "03/14/2021",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns error", () => {
expect(subject.body.message[0].message).toEqual(
`"ends_at" must be greater than "ref:starts_at"`
)
})
})
})

View File

@@ -71,7 +71,13 @@ export default async (req, res) => {
.required(),
is_disabled: Validator.boolean().default(false),
starts_at: Validator.date().optional(),
ends_at: Validator.date().optional(),
ends_at: Validator.date()
.greater(Validator.ref("starts_at"))
.optional(),
valid_duration: Validator.string()
.isoDuration()
.allow(null)
.optional(),
usage_limit: Validator.number()
.positive()
.optional(),

View File

@@ -37,9 +37,9 @@ export default async (req, res) => {
try {
const discountService = req.scope.resolve("discountService")
await discountService.createDynamicCode(discount_id, value)
const created = await discountService.createDynamicCode(discount_id, value)
const discount = await discountService.retrieve(discount_id, {
const discount = await discountService.retrieve(created.id, {
relations: ["rule", "rule.valid_for", "regions"],
})

View File

@@ -74,6 +74,7 @@ export const defaultFields = [
"updated_at",
"deleted_at",
"metadata",
"valid_duration",
]
export const defaultRelations = [

View File

@@ -68,7 +68,16 @@ export default async (req, res) => {
.optional(),
is_disabled: Validator.boolean().optional(),
starts_at: Validator.date().optional(),
ends_at: Validator.date().optional(),
ends_at: Validator.when("starts_at", {
not: undefined,
then: Validator.date()
.greater(Validator.ref("starts_at"))
.optional(),
otherwise: Validator.date().optional(),
}),
valid_duration: Validator.string()
.isoDuration().allow(null)
.optional(),
usage_limit: Validator.number()
.positive()
.optional(),
@@ -78,6 +87,7 @@ export default async (req, res) => {
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}

View File

@@ -0,0 +1,14 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class validDurationForDiscount1631696624528 implements MigrationInterface {
name = 'validDurationForDiscount1631696624528'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "discount" ADD "valid_duration" character varying`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "discount" DROP COLUMN "valid_duration"`);
}
}

View File

@@ -44,7 +44,7 @@ export class Discount {
@Column({ nullable: true })
parent_discount_id: string
@ManyToOne(() => Discount)
@JoinColumn({ name: "parent_discount_id" })
parent_discount: Discount
@@ -58,6 +58,9 @@ export class Discount {
@Column({ type: resolveDbType("timestamptz"), nullable: true })
ends_at: Date
@Column({ nullable: true })
valid_duration: string
@ManyToMany(() => Region, { cascade: true })
@JoinTable({
name: "discount_regions",

View File

@@ -1471,6 +1471,12 @@ describe("CartService", () => {
})
describe("applyDiscount", () => {
const getOffsetDate = offset => {
const date = new Date()
date.setDate(date.getDate() + offset)
return date
}
const cartRepository = MockRepository({
findOneWithRelations: (rels, q) => {
if (q.where.id === IdMap.getId("with-d")) {
@@ -1538,6 +1544,69 @@ describe("CartService", () => {
},
})
}
if (code === "EarlyDiscount") {
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(1),
ends_at: getOffsetDate(10),
})
}
if (code === "ExpiredDiscount") {
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
ends_at: getOffsetDate(-1),
starts_at: getOffsetDate(-10),
})
}
if (code === "ExpiredDynamicDiscount") {
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
is_dynamic: true,
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(-1),
})
}
if (code === "ExpiredDynamicDiscountEndDate") {
return Promise.resolve({
id: IdMap.getId("10off"),
is_dynamic: true,
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(-3),
valid_duration: "P0Y0M1D",
})
}
if (code === "ValidDiscount") {
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(10),
})
}
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
@@ -1688,6 +1757,76 @@ describe("CartService", () => {
})
})
it("successfully applies valid discount with expiration date to cart", async () => {
await cartService.update(IdMap.getId("fr-cart"), {
discounts: [
{
code: "ValidDiscount",
},
],
})
expect(eventBusService.emit).toHaveBeenCalledTimes(1)
expect(eventBusService.emit).toHaveBeenCalledWith(
"cart.updated",
expect.any(Object)
)
expect(cartRepository.save).toHaveBeenCalledTimes(1)
expect(cartRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("cart"),
region_id: IdMap.getId("good"),
discount_total: 0,
shipping_total: 0,
subtotal: 0,
tax_total: 0,
total: 0,
discounts: [
{
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: expect.any(Date),
ends_at: expect.any(Date),
},
],
})
})
it("throws if discount is applied too before it's valid", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {
discounts: [{ code: "EarlyDiscount" }],
})
).rejects.toThrow("Discount is not valid yet")
})
it("throws if expired discount is applied", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {
discounts: [{ code: "ExpiredDiscount" }],
})
).rejects.toThrow("Discount is expired")
})
it("throws if expired dynamic discount is applied", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {
discounts: [{ code: "ExpiredDynamicDiscount" }],
})
).rejects.toThrow("Discount is expired")
})
it("throws if expired dynamic discount is applied after ends_at", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {
discounts: [{ code: "ExpiredDynamicDiscountEndDate" }],
})
).rejects.toThrow("Discount is expired")
})
it("throws if discount limit is reached", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {

View File

@@ -55,6 +55,74 @@ describe("DiscountService", () => {
expect(discountRepository.save).toHaveBeenCalledTimes(1)
})
it("successfully creates discount with start and end dates", async () => {
await discountService.create({
code: "test",
rule: {
type: "percentage",
allocation: "total",
value: 20,
},
starts_at: new Date("03/14/2021"),
ends_at: new Date("03/15/2021"),
regions: [IdMap.getId("france")],
})
expect(discountRuleRepository.create).toHaveBeenCalledTimes(1)
expect(discountRuleRepository.create).toHaveBeenCalledWith({
type: "percentage",
allocation: "total",
value: 20,
})
expect(discountRuleRepository.save).toHaveBeenCalledTimes(1)
expect(discountRepository.create).toHaveBeenCalledTimes(1)
expect(discountRepository.create).toHaveBeenCalledWith({
code: "TEST",
rule: expect.anything(),
regions: [{ id: IdMap.getId("france") }],
starts_at: new Date("03/14/2021"),
ends_at: new Date("03/15/2021"),
})
expect(discountRepository.save).toHaveBeenCalledTimes(1)
})
it("successfully creates discount with start date and a valid duration", async () => {
await discountService.create({
code: "test",
rule: {
type: "percentage",
allocation: "total",
value: 20,
},
starts_at: new Date("03/14/2021"),
valid_duration: "P0Y0M1D",
regions: [IdMap.getId("france")],
})
expect(discountRuleRepository.create).toHaveBeenCalledTimes(1)
expect(discountRuleRepository.create).toHaveBeenCalledWith({
type: "percentage",
allocation: "total",
value: 20,
})
expect(discountRuleRepository.save).toHaveBeenCalledTimes(1)
expect(discountRepository.create).toHaveBeenCalledTimes(1)
expect(discountRepository.create).toHaveBeenCalledWith({
code: "TEST",
rule: expect.anything(),
regions: [{ id: IdMap.getId("france") }],
starts_at: new Date("03/14/2021"),
valid_duration: "P0Y0M1D",
})
expect(discountRepository.save).toHaveBeenCalledTimes(1)
})
})
describe("retrieve", () => {
@@ -376,6 +444,7 @@ describe("DiscountService", () => {
id: "parent",
is_dynamic: true,
rule_id: "parent_rule",
valid_duration: "P1Y",
}),
})
@@ -412,6 +481,8 @@ describe("DiscountService", () => {
rule_id: "parent_rule",
parent_discount_id: "parent",
code: "HI",
usage_limit: undefined,
ends_at: expect.any(Date),
})
})
})

View File

@@ -887,6 +887,21 @@ class CartService extends BaseService {
)
}
const today = new Date()
if (discount.starts_at > today) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is not valid yet"
)
}
if (discount.ends_at && discount.ends_at < today) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is expired"
)
}
let regions = discount.regions
if (discount.parent_discount_id) {
const parent = await this.discountService_.retrieve(

View File

@@ -2,6 +2,8 @@ import _ from "lodash"
import randomize from "randomatic"
import { BaseService } from "medusa-interfaces"
import { Validator, MedusaError } from "medusa-core-utils"
import { MedusaErrorCodes } from "medusa-core-utils/dist/errors"
import { parse, toSeconds } from "iso8601-duration"
import { Brackets, ILike } from "typeorm"
/**
@@ -270,6 +272,15 @@ class DiscountService extends BaseService {
const { rule, metadata, regions, ...rest } = update
if (rest.ends_at) {
if (discount.starts_at >= new Date(update.ends_at)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`"ends_at" must be greater than "starts_at"`
)
}
}
if (regions) {
discount.regions = await Promise.all(
regions.map(regionId => this.regionService_.retrieve(regionId))
@@ -329,6 +340,13 @@ class DiscountService extends BaseService {
usage_limit: discount.usage_limit,
}
if (discount.valid_duration) {
const lastValidDate = new Date()
lastValidDate.setSeconds(
lastValidDate.getSeconds() + toSeconds(parse(discount.valid_duration))
)
toCreate.ends_at = lastValidDate
}
const created = await discountRepo.create(toCreate)
const result = await discountRepo.save(created)
return result