Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create standard coupon #6734

Merged
merged 5 commits into from
Jan 30, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/api-plugin-promotions-coupons/src/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { createRequire } from "module";
import schemas from "./schemas/index.js";
import mutations from "./mutations/index.js";
import queries from "./queries/index.js";
import resolvers from "./resolvers/index.js";
import triggers from "./triggers/index.js";
import { Coupon } from "./simpleSchemas.js";

const require = createRequire(import.meta.url);
const pkg = require("../package.json");
Expand All @@ -17,13 +19,26 @@ export default async function register(app) {
label: pkg.label,
name: pkg.name,
version: pkg.version,
collections: {
Coupons: {
name: "Coupons",
indexes: [
[{ shopId: 1, code: 1 }],
[{ shopId: 1, promotionId: 1 }]
]
}
},
promotions: {
triggers
},
graphQL: {
resolvers,
schemas
},
mutations
mutations,
queries,
simpleSchemas: {
Coupon
}
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import _ from "lodash";
import SimpleSchema from "simpl-schema";
import Random from "@reactioncommerce/random";
import ReactionError from "@reactioncommerce/reaction-error";
import { Coupon } from "../simpleSchemas.js";

const inputSchema = new SimpleSchema({
shopId: String,
promotionId: String,
code: String,
canUseInStore: Boolean,
maxUsageTimesPerUser: {
type: Number,
optional: true
},
maxUsageTimes: {
type: Number,
optional: true
}
});

/**
* @method createStandardCoupon
* @summary Create a standard coupon mutation
* @param {Object} context - The application context
* @param {Object} input - The coupon input to create
* @returns {Promise<Object>} with created coupon result
*/
export default async function createStandardCoupon(context, input) {
inputSchema.validate(input);

const { collections: { Coupons, Promotions } } = context;
const { shopId, promotionId, code } = input;

const promotion = await Promotions.findOne({ _id: promotionId, shopId });
if (!promotion) throw new ReactionError("not-found", "Promotion not found");

const existsCoupons = await Coupons.find({ code, shopId }).toArray();
if (existsCoupons.length > 0) {
const promotionIds = _.map(existsCoupons, "promotionId");
const promotions = await Promotions.find({ _id: { $in: promotionIds } }).toArray();

for (const existsPromotion of promotions) {
if (existsPromotion.startDate <= promotion.startDate && existsPromotion.endDate >= promotion.endDate) {
throw new ReactionError("invalid-params", `A coupon code ${code} already exists in this promotion window`);
}
}
}

const now = new Date();
const coupon = {
_id: Random.id(),
code: input.code,
shopId,
promotionId,
expirationDate: promotion.endDate,
canUseInStore: input.canUseInStore || false,
maxUsageTimesPerUser: input.maxUsageTimesPerUser || 0,
maxUsageTimes: input.maxUsageTimes || 0,
usedCount: 0,
createdAt: now,
updatedAt: now
};

Coupon.validate(coupon);

const results = await Coupons.insertOne(coupon);

const { insertedId, result } = results;
coupon._id = insertedId;
return { success: result.n === 1, coupon };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js";
import createStandardCoupon from "./createStandardCoupon.js";

test("throws if validation check fails", async () => {
const input = { code: "CODE" };

try {
await createStandardCoupon(mockContext, input);
} catch (error) {
expect(error.error).toEqual("validation-error");
}
});

test("throws error when promotion does not exist", async () => {
const input = { code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true };
mockContext.collections = {
Coupons: {
findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(null))
},
Promotions: {
findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(null))
}
};

try {
await createStandardCoupon(mockContext, input);
} catch (error) {
expect(error.message).toEqual("Promotion not found");
}
});

test("throws error when coupon code already exists in promotion window", async () => {
const now = new Date();
const input = { code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true };
const promotion = { _id: "123", startDate: now, endDate: now };
const existsPromotion = { _id: "1234", startDate: now, endDate: now };
const coupon = { _id: "123", code: "CODE", promotionId: "123" };
mockContext.collections = {
Coupons: {
find: jest.fn().mockReturnValue({
toArray: jest.fn().mockResolvedValueOnce(Promise.resolve([coupon]))
})
},
Promotions: {
findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(promotion)),
find: jest.fn().mockReturnValue({
toArray: jest.fn().mockResolvedValueOnce(Promise.resolve([existsPromotion]))
})
}
};

try {
await createStandardCoupon(mockContext, input);
} catch (error) {
expect(error.message).toEqual("A coupon code CODE already exists in this promotion window");
}
});

test("should insert a new coupon and return the created results", async () => {
const now = new Date();
const input = { code: "CODE", shopId: "123", promotionId: "123", canUseInStore: true };
const promotion = { _id: "123", endDate: now };

mockContext.collections = {
Coupons: {
find: jest.fn().mockReturnValue({
toArray: jest.fn().mockResolvedValueOnce([])
}),
// eslint-disable-next-line id-length
insertOne: jest.fn().mockResolvedValueOnce(Promise.resolve({ insertedId: "123", result: { n: 1 } }))
},
Promotions: {
findOne: jest.fn().mockResolvedValueOnce(Promise.resolve(promotion))
}
};

const result = await createStandardCoupon(mockContext, input);

expect(mockContext.collections.Coupons.insertOne).toHaveBeenCalledTimes(1);
expect(mockContext.collections.Coupons.find).toHaveBeenCalledTimes(1);

expect(result).toEqual({
success: true,
coupon: {
_id: "123",
canUseInStore: true,
code: "CODE",
createdAt: jasmine.any(Date),
expirationDate: now,
maxUsageTimes: 0,
maxUsageTimesPerUser: 0,
promotionId: "123",
shopId: "123",
updatedAt: jasmine.any(Date),
usedCount: 0
}
vanpho93 marked this conversation as resolved.
Show resolved Hide resolved
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import applyCouponToCart from "./applyCouponToCart.js";
import createStandardCoupon from "./createStandardCoupon.js";

export default {
applyCouponToCart
applyCouponToCart,
createStandardCoupon
};
12 changes: 12 additions & 0 deletions packages/api-plugin-promotions-coupons/src/queries/coupon.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* @summary return a single coupon based on shopId and _id
* @param {Object} context - the application context
* @param {String} shopId - The id of the shop
* @param {String} _id - The unencoded id of the coupon
* @return {Object} - The coupon or null
*/
export default async function coupon(context, { shopId, _id }) {
const { collections: { Coupons } } = context;
const singleCoupon = await Coupons.findOne({ shopId, _id });
return singleCoupon;
}
34 changes: 34 additions & 0 deletions packages/api-plugin-promotions-coupons/src/queries/coupons.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* @summary return a possibly filtered list of coupons
* @param {Object} context - The application context
* @param {String} shopId - The shopId to query for
* @param {Object} filter - optional filter parameters
* @return {Promise<Array<Coupon>>} - A list of coupons
*/
export default async function coupons(context, shopId, filter) {
const { collections: { Coupons } } = context;

const selector = { shopId };

if (filter) {
const { expirationDate, promotionId, code, userId } = filter;

if (expirationDate) {
selector.expirationDate = { $gte: expirationDate };
}

if (promotionId) {
selector.promotionId = promotionId;
}

if (code) {
selector.code = code;
}

if (userId) {
selector.userId = userId;
}
}

return Coupons.find(selector);
}
7 changes: 7 additions & 0 deletions packages/api-plugin-promotions-coupons/src/queries/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import coupon from "./coupon.js";
import coupons from "./coupons.js";

export default {
coupon,
coupons
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* @method createStandardCoupon
* @summary Create a standard coupon mutation
* @param {Object} _ unused
* @param {Object} args.input - The input arguments
* @param {Object} args.input.shopId - The shopId
* @param {Object} args.input.promotionId - The promotion ID
* @param {Object} context - The application context
* @returns {Promise<Object>} with created coupon result
*/
export default async function createStandardCoupon(_, { input }, context) {
const { shopId } = input;

await context.validatePermissions("reaction:legacy:promotions", "create", { shopId });

const createCouponResult = await context.mutations.createStandardCoupon(context, input);
return createCouponResult;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import mockContext from "@reactioncommerce/api-utils/tests/mockContext.js";
import createStandardCoupon from "./createStandardCoupon.js";

test("throws if permission check fails", async () => {
const input = { name: "Test coupon", code: "CODE" };
mockContext.validatePermissions.mockResolvedValue(Promise.reject(new Error("Access Denied")));

try {
await createStandardCoupon(null, { input }, mockContext);
} catch (error) {
expect(error.message).toEqual("Access Denied");
}
});

test("calls mutations.createStandardCoupon and returns the result", async () => {
const input = { name: "Test coupon", code: "CODE" };
const result = { _id: "123" };
mockContext.validatePermissions.mockResolvedValue(Promise.resolve());
mockContext.mutations = {
createStandardCoupon: jest.fn().mockName("mutations.createStandardCoupon").mockReturnValueOnce(Promise.resolve(result))
};

const createdCoupon = await createStandardCoupon(null, { input }, mockContext);

expect(createdCoupon).toEqual(result);
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import applyCouponToCart from "./applyCouponToCart.js";
import createStandardCoupon from "./createStandardCoupon.js";

export default {
applyCouponToCart
applyCouponToCart,
createStandardCoupon
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* @summary Get a coupon for a promotion
* @param {Object} promotion - The promotion object
* @param {String} promotion._id - The promotion ID
* @param {Object} args - unused
* @param {Object} context - The context object
* @returns {Promise<Object>} A coupon object
*/
export default async function getPreviewPromotionCoupon(promotion, args, context) {
const { collections: { Coupons } } = context;
const coupon = await Coupons.findOne({ promotionId: promotion._id });
return coupon;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import getPreviewPromotionCoupon from "./getPreviewPromotionCoupon.js";

export default {
coupon: getPreviewPromotionCoupon
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* @summary query the coupons collection for a single coupon
* @param {Object} _ - unused
* @param {Object} args - an object of all arguments that were sent by the client
* @param {String} args.shopId - Shop id of the coupon
* @param {Object} context - an object containing the per-request state
* @returns {Promise<Object>} A coupon record or null
*/
export default async function coupon(_, args, context) {
const { input } = args;
const { shopId, _id } = input;
await context.validatePermissions("reaction:legacy:promotions", "read", { shopId });
return context.queries.coupon(context, {
shopId, _id
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import getPaginatedResponse from "@reactioncommerce/api-utils/graphql/getPaginatedResponse.js";
import wasFieldRequested from "@reactioncommerce/api-utils/graphql/wasFieldRequested.js";

/**
* @summary Query for a list of coupons
* @param {Object} _ - unused
* @param {Object} args - an object of all arguments that were sent by the client
* @param {String} args.shopId - id of user to query
* @param {Object} context - an object containing the per-request state
* @param {Object} info Info about the GraphQL request
* @returns {Promise<Object>} Products
*/
export default async function coupons(_, args, context, info) {
const { shopId, filter, ...connectionArgs } = args;
await context.validatePermissions("reaction:legacy:promotions", "read", { shopId });
const query = await context.queries.coupons(context, shopId, filter);

return getPaginatedResponse(query, connectionArgs, {
includeHasNextPage: wasFieldRequested("pageInfo.hasNextPage", info),
includeHasPreviousPage: wasFieldRequested("pageInfo.hasPreviousPage", info),
includeTotalCount: wasFieldRequested("totalCount", info)
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import coupon from "./coupon.js";
import coupons from "./coupons.js";

export default {
coupon,
coupons
};
Loading