From aff1ec3390c38848e1d1ac282795e1dbf584f4c1 Mon Sep 17 00:00:00 2001 From: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Tue, 7 Apr 2020 12:48:57 +0200 Subject: [PATCH] Adds DiscountService and TotalsService (#26) --- packages/medusa/src/models/__mocks__/cart.js | 57 ++++ .../medusa/src/models/__mocks__/discount.js | 146 +++++++++ packages/medusa/src/models/cart.js | 3 +- packages/medusa/src/models/discount.js | 19 ++ .../src/models/schemas/discount-rule.js | 28 ++ .../medusa/src/services/__mocks__/cart.js | 53 ++++ .../medusa/src/services/__mocks__/discount.js | 35 ++ .../src/services/__mocks__/product-variant.js | 12 +- .../medusa/src/services/__mocks__/totals.js | 16 + .../medusa/src/services/__tests__/cart.js | 123 +++++++ .../medusa/src/services/__tests__/discount.js | 233 ++++++++++++++ .../medusa/src/services/__tests__/totals.js | 116 +++++++ packages/medusa/src/services/cart.js | 86 +++++ packages/medusa/src/services/discount.js | 300 ++++++++++++++++++ packages/medusa/src/services/totals.js | 179 +++++++++++ 15 files changed, 1404 insertions(+), 2 deletions(-) create mode 100644 packages/medusa/src/models/__mocks__/discount.js create mode 100644 packages/medusa/src/models/discount.js create mode 100644 packages/medusa/src/models/schemas/discount-rule.js create mode 100644 packages/medusa/src/services/__mocks__/discount.js create mode 100644 packages/medusa/src/services/__mocks__/totals.js create mode 100644 packages/medusa/src/services/__tests__/discount.js create mode 100644 packages/medusa/src/services/__tests__/totals.js create mode 100644 packages/medusa/src/services/discount.js create mode 100644 packages/medusa/src/services/totals.js diff --git a/packages/medusa/src/models/__mocks__/cart.js b/packages/medusa/src/models/__mocks__/cart.js index ed9beefa7d..f7e319450d 100644 --- a/packages/medusa/src/models/__mocks__/cart.js +++ b/packages/medusa/src/models/__mocks__/cart.js @@ -1,4 +1,5 @@ import { IdMap } from "medusa-test-utils" +import { discounts } from "./discount" export const carts = { emptyCart: { @@ -255,6 +256,59 @@ export const carts = { discounts: [], customer_id: "", }, + discountCartWithExisting: { + _id: IdMap.getId("discount-cart-with-existing"), + discounts: [discounts.item10Percent], + region_id: IdMap.getId("region-france"), + items: [ + { + _id: IdMap.getId("line"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: [ + { + unit_price: 8, + variant: { + _id: IdMap.getId("eur-8-us-10"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + { + unit_price: 10, + variant: { + _id: IdMap.getId("eur-10-us-12"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + ], + quantity: 10, + }, + { + _id: IdMap.getId("existingLine"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 10, + variant: { + _id: IdMap.getId("eur-10-us-12"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + }, } export const CartModelMock = { @@ -285,6 +339,9 @@ export const CartModelMock = { if (query._id === IdMap.getId("complete-cart")) { return Promise.resolve(carts.completeCart) } + if (query._id === IdMap.getId("discount-cart-with-existing")) { + return Promise.resolve(carts.discountCartWithExisting) + } return Promise.resolve(undefined) }), } diff --git a/packages/medusa/src/models/__mocks__/discount.js b/packages/medusa/src/models/__mocks__/discount.js new file mode 100644 index 0000000000..b10d725564 --- /dev/null +++ b/packages/medusa/src/models/__mocks__/discount.js @@ -0,0 +1,146 @@ +import { IdMap } from "medusa-test-utils" + +export const discounts = { + total10Percent: { + _id: IdMap.getId("total10"), + code: "10%OFF", + discount_rule: { + type: "percentage", + allocation: "total", + value: 10, + }, + regions: [IdMap.getId("region-france")], + }, + item10Percent: { + _id: IdMap.getId("item10Percent"), + code: "MEDUSA", + discount_rule: { + 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")], + }, + total10Fixed: { + _id: IdMap.getId("total10Fixed"), + code: "MEDUSA", + discount_rule: { + type: "fixed", + allocation: "total", + value: 10, + }, + regions: [IdMap.getId("region-france")], + }, + item9Fixed: { + _id: IdMap.getId("item9Fixed"), + code: "MEDUSA", + discount_rule: { + 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")], + }, + item2Fixed: { + _id: IdMap.getId("item2Fixed"), + code: "MEDUSA", + discount_rule: { + 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")], + }, + item10FixedNoVariants: { + _id: IdMap.getId("item10FixedNoVariants"), + code: "MEDUSA", + discount_rule: { + type: "fixed", + allocation: "item", + value: 10, + valid_for: [], + }, + regions: [IdMap.getId("region-france")], + }, + expiredDiscount: { + _id: IdMap.getId("expired"), + code: "MEDUSA", + ends_at: new Date("December 17, 1995 03:24:00"), + discount_rule: { + type: "fixed", + allocation: "item", + value: 10, + valid_for: [], + }, + regions: [IdMap.getId("region-france")], + }, + freeShipping: { + _id: IdMap.getId("freeshipping"), + code: "FREESHIPPING", + discount_rule: { + type: "free_shipping", + allocation: "total", + value: 10, + valid_for: [], + }, + regions: [IdMap.getId("region-france")], + }, + USDiscount: { + _id: IdMap.getId("us-discount"), + code: "US10", + discount_rule: { + type: "free_shipping", + allocation: "total", + value: 10, + valid_for: [], + }, + regions: [IdMap.getId("us")], + }, + alreadyExists: { + code: "ALREADYEXISTS", + discount_rule: { + type: "percentage", + allocation: "total", + value: 20, + }, + regions: [IdMap.getId("fr-cart")], + }, +} + +export const DiscountModelMock = { + create: jest.fn().mockReturnValue(Promise.resolve()), + updateOne: jest.fn().mockImplementation((query, update) => { + return Promise.resolve() + }), + deleteOne: jest.fn().mockReturnValue(Promise.resolve()), + findOne: jest.fn().mockImplementation(query => { + if (query._id === IdMap.getId("total10")) { + return Promise.resolve(discounts.total10Percent) + } + if (query._id === IdMap.getId("item10Percent")) { + return Promise.resolve(discounts.item10Percent) + } + if (query._id === IdMap.getId("total10Fixed")) { + return Promise.resolve(discounts.total10Fixed) + } + if (query._id === IdMap.getId("item2Fixed")) { + return Promise.resolve(discounts.item2Fixed) + } + if (query._id === IdMap.getId("item10FixedNoVariants")) { + return Promise.resolve(discounts.item10FixedNoVariants) + } + if (query._id === IdMap.getId("expired")) { + return Promise.resolve(discounts.expiredDiscount) + } + if (query.code === "10%OFF") { + return Promise.resolve(discounts.total10Percent) + } + if (query.code === "aLrEaDyExIsts") { + return Promise.resolve(discounts.alreadyExists) + } + return Promise.resolve(undefined) + }), +} diff --git a/packages/medusa/src/models/cart.js b/packages/medusa/src/models/cart.js index 53b20e3a4d..fb9c504b0e 100644 --- a/packages/medusa/src/models/cart.js +++ b/packages/medusa/src/models/cart.js @@ -8,6 +8,7 @@ import LineItemSchema from "./schemas/line-item" import PaymentMethodSchema from "./schemas/payment-method" import ShippingMethodSchema from "./schemas/shipping-method" import AddressSchema from "./schemas/address" +import DiscountModel from "./discount" class CartModel extends BaseModel { static modelName = "Cart" @@ -18,7 +19,7 @@ class CartModel extends BaseModel { shipping_address: { type: AddressSchema, default: {} }, items: { type: [LineItemSchema], default: [] }, region_id: { type: String, required: true }, - discounts: { type: [String], default: [] }, + discounts: { type: [DiscountModel.schema], default: [] }, customer_id: { type: String, default: "" }, payment_sessions: { type: [PaymentMethodSchema], default: [] }, shipping_options: { type: [ShippingMethodSchema], default: [] }, diff --git a/packages/medusa/src/models/discount.js b/packages/medusa/src/models/discount.js new file mode 100644 index 0000000000..d77d883d5d --- /dev/null +++ b/packages/medusa/src/models/discount.js @@ -0,0 +1,19 @@ +import { BaseModel } from "medusa-interfaces" +import DiscountRule from "./schemas/discount-rule" + +class DiscountModel extends BaseModel { + static modelName = "Discount" + + static schema = { + code: { type: String, required: true, unique: true }, + discount_rule: { type: DiscountRule, required: true }, + usage_count: { type: Number, default: 0 }, + disabled: { type: Boolean, default: false }, + starts_at: { type: Date }, + ends_at: { type: Date }, + regions: { type: [String], default: [] }, + metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, + } +} + +export default DiscountModel diff --git a/packages/medusa/src/models/schemas/discount-rule.js b/packages/medusa/src/models/schemas/discount-rule.js new file mode 100644 index 0000000000..b508f7a669 --- /dev/null +++ b/packages/medusa/src/models/schemas/discount-rule.js @@ -0,0 +1,28 @@ +import mongoose from "mongoose" + +export default new mongoose.Schema({ + description: { type: String }, + // Fixed, percentage or free shipping is allowed as type. + // The fixed discount type can be used as normal coupon code, giftcards, + // store credits and possibly more. + // The percentage discount type can be used as normal coupon code, giftcards + // and possibly more. + // The free shipping discount type is used only to give free shipping. + type: { type: String, required: true }, // Fixed, percent, free shipping + // The value is simply the amount of discount a customer or user will have. + // This depends on the type above, since percentage can not be above 100. + value: { type: Number, required: true }, + // This is either total, meaning that the discount will be applied to the + // total price of the cart + // or item, meaning that the discount can be applied to the product variants + // in the valid_for array. Lastly the allocation is completely ignored if + // discount type is free shipping. + allocation: { type: String, required: true }, + // Id's of product variants. Depends on allocation. + // If total is set, then the valid_for will not be used for anything, + // since the discount will work for the cart total. Else if item allocation + // is chosen, then we will go through the cart and apply the coupon code to + // all the valid product variants. + valid_for: { type: [String], default: [] }, + usage_limit: { type: Number }, +}) diff --git a/packages/medusa/src/services/__mocks__/cart.js b/packages/medusa/src/services/__mocks__/cart.js index 1df0f0f538..462f2f0fda 100644 --- a/packages/medusa/src/services/__mocks__/cart.js +++ b/packages/medusa/src/services/__mocks__/cart.js @@ -131,6 +131,59 @@ export const carts = { discounts: [], customer_id: "", }, + discountCart: { + _id: IdMap.getId("discount-cart"), + discounts: [], + region_id: IdMap.getId("region-france"), + items: [ + { + _id: IdMap.getId("line"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: [ + { + unit_price: 8, + variant: { + _id: IdMap.getId("eur-8-us-10"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + { + unit_price: 10, + variant: { + _id: IdMap.getId("eur-10-us-12"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + ], + quantity: 10, + }, + { + _id: IdMap.getId("existingLine"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 10, + variant: { + _id: IdMap.getId("eur-10-us-12"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + }, } export const CartServiceMock = { diff --git a/packages/medusa/src/services/__mocks__/discount.js b/packages/medusa/src/services/__mocks__/discount.js new file mode 100644 index 0000000000..9b22d2a185 --- /dev/null +++ b/packages/medusa/src/services/__mocks__/discount.js @@ -0,0 +1,35 @@ +import { IdMap } from "medusa-test-utils" +import { discounts } from "../../models/__mocks__/discount" + +export const DiscountServiceMock = { + retrieveByCode: jest.fn().mockImplementation(data => { + if (data === "10%OFF") { + return Promise.resolve(discounts.total10Percent) + } + if (data === "FREESHIPPING") { + return Promise.resolve(discounts.freeShipping) + } + if (data === "US10") { + return Promise.resolve(discounts.USDiscount) + } + return Promise.resolve(undefined) + }), + retrieve: jest.fn().mockImplementation(data => { + if (data === IdMap.getId("total10")) { + return Promise.resolve(discounts.total10Percent) + } + if (data === IdMap.getId("item10Percent")) { + return Promise.resolve(discounts.item10Percent) + } + if (data === IdMap.getId("freeshipping")) { + return Promise.resolve(discounts.freeShipping) + } + return Promise.resolve(undefined) + }), +} + +const mock = jest.fn().mockImplementation(() => { + return DiscountServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/__mocks__/product-variant.js b/packages/medusa/src/services/__mocks__/product-variant.js index 7b777d83d7..84b73ef550 100644 --- a/packages/medusa/src/services/__mocks__/product-variant.js +++ b/packages/medusa/src/services/__mocks__/product-variant.js @@ -90,6 +90,11 @@ const invalidVariant = { ], } +const testVariant = { + _id: IdMap.getId("testVariant"), + title: "test variant", +} + const emptyVariant = { _id: "empty_option", title: "variant3", @@ -108,6 +113,7 @@ export const variants = { invalid_variant: invalidVariant, empty_variant: emptyVariant, eur10us12: eur10us12, + testVariant: testVariant, } export const ProductVariantServiceMock = { @@ -133,8 +139,12 @@ export const ProductVariantServiceMock = { if (variantId === "empty_option") { return Promise.resolve(emptyVariant) } - if (variantId === IdMap.getId("eur-10-us-12")) + if (variantId === IdMap.getId("eur-10-us-12")) { return Promise.resolve(eur10us12) + } + if (variantId === IdMap.getId("testVariant")) { + return Promise.resolve(testVariant) + } }), canCoverQuantity: jest.fn().mockImplementation((variantId, quantity) => { if (variantId === IdMap.getId("can-cover")) { diff --git a/packages/medusa/src/services/__mocks__/totals.js b/packages/medusa/src/services/__mocks__/totals.js new file mode 100644 index 0000000000..a7abc5eaf9 --- /dev/null +++ b/packages/medusa/src/services/__mocks__/totals.js @@ -0,0 +1,16 @@ +import { IdMap } from "medusa-test-utils" + +export const TotalsServiceMock = { + getSubTotal: jest.fn().mockImplementation(cart => { + if (cart._id === IdMap.getId("discount-cart")) { + return 280 + } + return 0 + }), +} + +const mock = jest.fn().mockImplementation(() => { + return TotalsServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index de8ba86b93..600a7bdb86 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -10,6 +10,8 @@ import { RegionServiceMock } from "../__mocks__/region" import { ShippingOptionServiceMock } from "../__mocks__/shipping-option" import { CartModelMock, carts } from "../../models/__mocks__/cart" import { LineItemServiceMock } from "../__mocks__/line-item" +import { DiscountModelMock, discounts } from "../../models/__mocks__/discount" +import { DiscountServiceMock } from "../__mocks__/discount" describe("CartService", () => { describe("retrieve", () => { @@ -1265,4 +1267,125 @@ describe("CartService", () => { }) }) }) + + describe("applyDiscount", () => { + const cartService = new CartService({ + cartModel: CartModelMock, + discountService: DiscountServiceMock, + }) + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("successfully applies discount to cart", async () => { + await cartService.applyDiscount(IdMap.getId("fr-cart"), "10%OFF") + expect(CartModelMock.findOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("fr-cart"), + }) + + expect(DiscountServiceMock.retrieveByCode).toHaveBeenCalledTimes(1) + expect(DiscountServiceMock.retrieveByCode).toHaveBeenCalledWith("10%OFF") + + expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("fr-cart"), + }, + { + $push: { discounts: discounts.total10Percent }, + } + ) + }) + + it("successfully applies discount to cart and removes old one", async () => { + await cartService.applyDiscount( + IdMap.getId("discount-cart-with-existing"), + "10%OFF" + ) + expect(CartModelMock.findOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("discount-cart-with-existing"), + }) + + expect(DiscountServiceMock.retrieveByCode).toHaveBeenCalledTimes(1) + expect(DiscountServiceMock.retrieveByCode).toHaveBeenCalledWith("10%OFF") + + expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("discount-cart-with-existing"), + }, + { + $push: { discounts: discounts.total10Percent }, + $pull: { discounts: { _id: IdMap.getId("item10Percent") } }, + } + ) + }) + + it("successfully applies free shipping", async () => { + await cartService.applyDiscount( + IdMap.getId("discount-cart-with-existing"), + "FREESHIPPING" + ) + expect(CartModelMock.findOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("discount-cart-with-existing"), + }) + + expect(DiscountServiceMock.retrieveByCode).toHaveBeenCalledTimes(1) + expect(DiscountServiceMock.retrieveByCode).toHaveBeenCalledWith( + "FREESHIPPING" + ) + + expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("discount-cart-with-existing"), + }, + { + $push: { discounts: discounts.freeShipping }, + } + ) + }) + + it("successfully resolves ", async () => { + await cartService.applyDiscount( + IdMap.getId("discount-cart-with-existing"), + "FREESHIPPING" + ) + expect(CartModelMock.findOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("discount-cart-with-existing"), + }) + + expect(DiscountServiceMock.retrieveByCode).toHaveBeenCalledTimes(1) + expect(DiscountServiceMock.retrieveByCode).toHaveBeenCalledWith( + "FREESHIPPING" + ) + + expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("discount-cart-with-existing"), + }, + { + $push: { discounts: discounts.freeShipping }, + } + ) + }) + + it("throws if discount is not available in region", async () => { + try { + await cartService.applyDiscount( + IdMap.getId("discount-cart-with-existing"), + "US10" + ) + } catch (error) { + expect(error.message).toEqual( + "The discount is not available in current region" + ) + } + }) + }) }) diff --git a/packages/medusa/src/services/__tests__/discount.js b/packages/medusa/src/services/__tests__/discount.js new file mode 100644 index 0000000000..97b2236d98 --- /dev/null +++ b/packages/medusa/src/services/__tests__/discount.js @@ -0,0 +1,233 @@ +import DiscountService from "../discount" +import { DiscountModelMock, discounts } from "../../models/__mocks__/discount" +import { IdMap } from "medusa-test-utils" +import { ProductVariantServiceMock } from "../__mocks__/product-variant" +import { RegionServiceMock } from "../__mocks__/region" + +describe("DiscountService", () => { + describe("create", () => { + const discountService = new DiscountService({ + discountModel: DiscountModelMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calls model layer create and normalizes code", async () => { + await discountService.create({ + code: "test", + discount_rule: { + type: "percentage", + allocation: "total", + value: 20, + }, + regions: [IdMap.getId("fr-cart")], + }) + + expect(DiscountModelMock.create).toHaveBeenCalledTimes(1) + expect(DiscountModelMock.create).toHaveBeenCalledWith({ + code: "TEST", + discount_rule: { + type: "percentage", + allocation: "total", + value: 20, + }, + regions: [IdMap.getId("fr-cart")], + }) + }) + }) + + describe("retrieve", () => { + let res + const discountService = new DiscountService({ + discountModel: DiscountModelMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calls model layer findOne", async () => { + res = await discountService.retrieve(IdMap.getId("total10")) + expect(DiscountModelMock.findOne).toHaveBeenCalledTimes(1) + expect(DiscountModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("total10"), + }) + }) + + it("successfully returns cart", () => { + expect(res).toEqual(discounts.total10Percent) + }) + }) + + describe("update", () => { + const discountService = new DiscountService({ + discountModel: DiscountModelMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calls model layer updateOne", async () => { + await discountService.update(IdMap.getId("total10"), { + code: "test", + }) + expect(DiscountModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(DiscountModelMock.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("total10") }, + { + $set: { code: "test" }, + }, + { runValidators: true } + ) + }) + + it("successfully calls model layer with discount_rule", async () => { + await discountService.update(IdMap.getId("total10"), { + discount_rule: { type: "fixed", value: 10, allocation: "total" }, + }) + expect(DiscountModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(DiscountModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("total10"), + }, + { + $set: { + discount_rule: { type: "fixed", value: 10, allocation: "total" }, + }, + }, + { runValidators: true } + ) + }) + + it("throws if metadata update is attempted", async () => { + try { + await discountService.update(IdMap.getId("total10"), { + metadata: { test: "test" }, + }) + } catch (error) { + expect(error.message).toEqual( + "Use setMetadata to update discount metadata" + ) + } + }) + }) + + describe("addValidVariant", () => { + const discountService = new DiscountService({ + discountModel: DiscountModelMock, + productVariantService: ProductVariantServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calls model layer updateOne", async () => { + await discountService.addValidVariant( + IdMap.getId("total10"), + IdMap.getId("testVariant") + ) + + expect(DiscountModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(DiscountModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("total10"), + }, + { + $push: { discount_rule: { valid_for: IdMap.getId("testVariant") } }, + }, + { runValidators: true } + ) + }) + }) + + describe("removeValidVariant", () => { + const discountService = new DiscountService({ + discountModel: DiscountModelMock, + productVariantService: ProductVariantServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calls model layer updateOne", async () => { + await discountService.removeValidVariant( + IdMap.getId("total10"), + IdMap.getId("testVariant") + ) + + expect(DiscountModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(DiscountModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("total10"), + }, + { + $pull: { discount_rule: { valid_for: IdMap.getId("testVariant") } }, + }, + { runValidators: true } + ) + }) + }) + + describe("addRegion", () => { + const discountService = new DiscountService({ + discountModel: DiscountModelMock, + regionService: RegionServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calls model layer updateOne", async () => { + await discountService.addRegion( + IdMap.getId("total10"), + IdMap.getId("testRegion") + ) + + expect(DiscountModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(DiscountModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("total10"), + }, + { + $push: { regions: IdMap.getId("testRegion") }, + }, + { runValidators: true } + ) + }) + }) + + describe("removeRegion", () => { + const discountService = new DiscountService({ + discountModel: DiscountModelMock, + regionService: RegionServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calls model layer updateOne", async () => { + await discountService.removeRegion( + IdMap.getId("total10"), + IdMap.getId("testRegion") + ) + + expect(DiscountModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(DiscountModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("total10"), + }, + { + $pull: { regions: IdMap.getId("testRegion") }, + }, + { runValidators: true } + ) + }) + }) +}) diff --git a/packages/medusa/src/services/__tests__/totals.js b/packages/medusa/src/services/__tests__/totals.js new file mode 100644 index 0000000000..44f88815fb --- /dev/null +++ b/packages/medusa/src/services/__tests__/totals.js @@ -0,0 +1,116 @@ +import TotalsService from "../totals" +import { ProductVariantServiceMock } from "../__mocks__/product-variant" +import { discounts } from "../../models/__mocks__/discount" +import { carts } from "../__mocks__/cart" +import { IdMap } from "medusa-test-utils" + +describe("TotalsService", () => { + describe("getAllocationItemDiscounts", () => { + let res + const totalsService = new TotalsService({ + productVariantService: ProductVariantServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calculates item with percentage discount", async () => { + res = await totalsService.getAllocationItemDiscounts( + discounts.item10Percent, + carts.frCart + ) + + expect(res).toEqual([ + { + lineItem: IdMap.getId("existingLine"), + variant: IdMap.getId("eur-10-us-12"), + amount: 1, + }, + ]) + }) + + it("calculates item with fixed discount", async () => { + res = await totalsService.getAllocationItemDiscounts( + discounts.item9Fixed, + carts.frCart + ) + + expect(res).toEqual([ + { + lineItem: IdMap.getId("existingLine"), + variant: IdMap.getId("eur-10-us-12"), + amount: 9, + }, + ]) + }) + + it("does not apply discount if no valid variants are provided", async () => { + res = await totalsService.getAllocationItemDiscounts( + discounts.item10FixedNoVariants, + carts.frCart + ) + + expect(res).toEqual([]) + }) + }) + + describe("getDiscountTotal", () => { + let res + const totalsService = new TotalsService({ + productVariantService: ProductVariantServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + carts.discountCart.discounts = [] + }) + + it("calculate total precentage discount", async () => { + carts.discountCart.discounts.push(discounts.total10Percent) + res = await totalsService.getDiscountTotal(carts.discountCart) + + expect(res).toEqual(252) + }) + + it("calculate item fixed discount", async () => { + carts.discountCart.discounts.push(discounts.item2Fixed) + res = await totalsService.getDiscountTotal(carts.discountCart) + + expect(res).toEqual(278) + }) + + it("calculate item percentage discount", async () => { + carts.discountCart.discounts.push(discounts.item10Percent) + res = await totalsService.getDiscountTotal(carts.discountCart) + + expect(res).toEqual(279) + }) + + it("calculate total fixed discount", async () => { + carts.discountCart.discounts.push(discounts.total10Fixed) + res = await totalsService.getDiscountTotal(carts.discountCart) + + expect(res).toEqual(270) + }) + + it("ignores discount if expired", async () => { + carts.discountCart.discounts.push(discounts.expiredDiscount) + res = await totalsService.getDiscountTotal(carts.discountCart) + + expect(res).toEqual(280) + }) + + it("returns cart subtotal if no discounts are applied", async () => { + res = await totalsService.getDiscountTotal(carts.discountCart) + + expect(res).toEqual(280) + }) + + it("returns 0 if no items are in cart", async () => { + res = await totalsService.getDiscountTotal(carts.regionCart) + + expect(res).toEqual(0) + }) + }) +}) diff --git a/packages/medusa/src/services/cart.js b/packages/medusa/src/services/cart.js index d6cc8b43b5..5a87e7d72b 100644 --- a/packages/medusa/src/services/cart.js +++ b/packages/medusa/src/services/cart.js @@ -16,6 +16,7 @@ class CartService extends BaseService { regionService, lineItemService, shippingOptionService, + discountService, }) { super() @@ -42,6 +43,9 @@ class CartService extends BaseService { /** @private @const {ShippingOptionsService} */ this.shippingOptionService_ = shippingOptionService + + /** @private @const {DiscountService} */ + this.discountService_ = discountService } /** @@ -410,6 +414,88 @@ class CartService extends BaseService { } ) } + /** + * Updates the cart's discounts. + * If discount besides free shipping is already applied, this + * will be overwritten + * Throws if discount regions does not include the cart region + * @param {string} cartId - the id of the cart to update + * @param {string} discountCode - the discount code + * @return {Promise} the result of the update operation + */ + async applyDiscount(cartId, discountCode) { + const cart = await this.retrieve(cartId) + const discount = await this.discountService_.retrieveByCode(discountCode) + if (!discount.regions.includes(cart.region_id)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "The discount is not available in current region" + ) + } + + // if discount is already there, we simply resolve + if (cart.discounts.includes(discount._id)) { + return Promise.resolve() + } + + // find the current discounts (if there) + // partition them into shipping and other + const [shippingDisc, otherDisc] = _.partition( + cart.discounts, + d => d.discount_rule.type === "free_shipping" + ) + + // if no shipping exists and the one to apply is shipping, we simply add it + // else we remove the current shipping and add the other one + if ( + shippingDisc.length === 0 && + discount.discount_rule.type === "free_shipping" + ) { + return this.cartModel_.updateOne( + { + _id: cart._id, + }, + { + $push: { discounts: discount }, + } + ) + } else if ( + shippingDisc.length > 0 && + discount.discount_rule.type === "free_shipping" + ) { + return this.cartModel_.updateOne( + { + _id: cart._id, + }, + { + $pull: { discounts: { _id: shippingDisc[0]._id } }, + $push: { discounts: discount }, + } + ) + } + + // replace the current discount if there, else add the new one + if (otherDisc.length === 0) { + return this.cartModel_.updateOne( + { + _id: cart._id, + }, + { + $push: { discounts: discount }, + } + ) + } else { + return this.cartModel_.updateOne( + { + _id: cart._id, + }, + { + $pull: { discounts: { _id: otherDisc[0]._id } }, + $push: { discounts: discount }, + } + ) + } + } /** * A payment method represents a way for the customer to pay. The payment diff --git a/packages/medusa/src/services/discount.js b/packages/medusa/src/services/discount.js new file mode 100644 index 0000000000..8698a34d29 --- /dev/null +++ b/packages/medusa/src/services/discount.js @@ -0,0 +1,300 @@ +import { BaseService } from "medusa-interfaces" +import { Validator, MedusaError } from "medusa-core-utils" +import _ from "lodash" + +/** + * Provides layer to manipulate discounts. + * @implements BaseService + */ +class DiscountService extends BaseService { + constructor({ + discountModel, + totalsService, + productVariantService, + regionService, + }) { + super() + + /** @private @const {DiscountModel} */ + this.discountModel_ = discountModel + + /** @private @const {TotalsService} */ + this.totalsService_ = totalsService + + /** @private @const {ProductVariantService} */ + this.productVariantService_ = productVariantService + + /** @private @const {RegionService} */ + this.regionService_ = regionService + } + + /** + * Validates discount id + * @param {string} rawId - the raw id to validate + * @return {string} the validated id + */ + validateId_(rawId) { + const schema = Validator.objectId() + const { value, error } = schema.validate(rawId) + if (error) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "The discount id could not be casted to an ObjectId" + ) + } + + return value + } + + /** + * Validates discount rules + * @param {DiscountRule} discountRule - the discount rule to validate + * @return {DiscountRule} the validated discount rule + */ + validateDiscountRule_(discountRule) { + const schema = Validator.object().keys({ + description: Validator.string(), + type: Validator.string().required(), + value: Validator.number() + .positive() + .required(), + allocation: Validator.string().required(), + valid_for: Validator.array().items(Validator.string()), + user_limit: Validator.number(), + total_limit: Validator.number(), + }) + + const { value, error } = schema.validate(discountRule) + if (error) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + error.details[0].message + ) + } + + if (value.type === "percentage" && value.value > 100) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Discount value above 100 is not allowed when type is percentage" + ) + } + + return value + } + + /** + * Used to normalize discount codes to uppercase. + * @param {string} discountCode - the discount code to normalize + * @return {string} the normalized discount code + */ + normalizeDiscountCode_(discountCode) { + return discountCode.toUpperCase() + } + + /** + * Creates a discount with provided data given that the data is validated. + * Normalizes discount code to uppercase. + * @param {Discount} discount - the discount data to create + * @return {Promise} the result of the create operation + */ + async create(discount) { + await this.validateDiscountRule_(discount.discount_rule) + + discount.code = this.normalizeDiscountCode_(discount.code) + + return this.discountModel_.create(discount).catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + /** + * Gets a discount by id. + * @param {string} discountId - id of discount to retrieve + * @return {Promise} the discount document + */ + async retrieve(discountId) { + const validatedId = this.validateId_(discountId) + const discount = await this.discountModel_ + .findOne({ _id: validatedId }) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + + if (!discount) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Discount with ${discountId} was not found` + ) + } + return discount + } + + /** + * Gets a discount by discount code. + * @param {string} discountCode - discount code of discount to retrieve + * @return {Promise} the discount document + */ + async retrieveByCode(discountCode) { + discountCode = this.normalizeDiscountCode_(discountCode) + const discount = await this.discountModel_ + .findOne({ code: discountCode }) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + + if (!discount) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Discount with code ${discountCode} was not found` + ) + } + return discount + } + + /** + * Updates a discount. + * @param {string} discountId - discount id of discount to update + * @param {Discount} update - the data to update the discount with + * @return {Promise} the result of the update operation + */ + async update(discountId, update) { + const discount = await this.retrieve(discountId) + + if (update.metadata) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Use setMetadata to update discount metadata" + ) + } + + if (update.discount_rule) { + update.discount_rule = this.validateDiscountRule_(update.discount_rule) + } + + return this.discountModel_.updateOne( + { _id: discount._id }, + { $set: update }, + { runValidators: true } + ) + } + + /** + * Adds a valid variant to the discount rule valid_for array. + * @param {string} discountId - id of discount + * @param {string} variantId - id of variant to add + * @return {Promise} the result of the update operation + */ + async addValidVariant(discountId, variantId) { + const discount = await this.retrieve(discountId) + + const variant = await this.productVariantService_.retrieve(variantId) + + return this.discountModel_.updateOne( + { _id: discount._id }, + { $push: { discount_rule: { valid_for: variant._id } } }, + { runValidators: true } + ) + } + + /** + * Removes a valid variant from the discount rule valid_for array + * @param {string} discountId - id of discount + * @param {string} variantId - id of variant to add + * @return {Promise} the result of the update operation + */ + async removeValidVariant(discountId, variantId) { + const discount = await this.retrieve(discountId) + + const variant = await this.productVariantService_.retrieve(variantId) + + return this.discountModel_.updateOne( + { _id: discount._id }, + { $pull: { discount_rule: { valid_for: variant._id } } }, + { runValidators: true } + ) + } + + /** + * 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) { + const discount = await this.retrieve(discountId) + + const region = await this.regionService_.retrieve(regionId) + + return this.discountModel_.updateOne( + { _id: discount._id }, + { $push: { regions: region._id } }, + { runValidators: true } + ) + } + + /** + * Removes a region from the discount regions array. + * @param {string} discountId - id of discount + * @param {string} regionId - id of region to remove + * @return {Promise} the result of the update operation + */ + async removeRegion(discountId, regionId) { + const discount = await this.retrieve(discountId) + + const region = await this.regionService_.retrieve(regionId) + + return this.discountModel_.updateOne( + { _id: discount._id }, + { $pull: { regions: region._id } }, + { runValidators: true } + ) + } + + /** + * Deletes a discount idempotently + * @param {string} discountId - id of discount to delete + * @return {Promise} the result of the delete operation + */ + async delete(discountId) { + let discount + try { + discount = await this.retrieve(discountId) + } catch (error) { + // Delete is idempotent, but we return a promise to allow then-chaining + return Promise.resolve() + } + + return this.discountModel_.deleteOne({ _id: discount._id }).catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + /** + * Dedicated method to set metadata for a discount. + * To ensure that plugins does not overwrite each + * others metadata fields, setMetadata is provided. + * @param {string} discountId - the id to apply metadata to. + * @param {string} key - key for metadata field + * @param {string} value - value for metadata field. + * @return {Promise} resolves to the updated result. + */ + setMetadata(discountId, key, value) { + const validatedId = this.validateId_(discountId) + + if (typeof key !== "string") { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Key type is invalid. Metadata keys must be strings" + ) + } + + const keyPath = `metadata.${key}` + return this.discountModel_ + .updateOne({ _id: validatedId }, { $set: { [keyPath]: value } }) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } +} + +export default DiscountService diff --git a/packages/medusa/src/services/totals.js b/packages/medusa/src/services/totals.js new file mode 100644 index 0000000000..79279f18a0 --- /dev/null +++ b/packages/medusa/src/services/totals.js @@ -0,0 +1,179 @@ +import _ from "lodash" +import { BaseService } from "medusa-interfaces" + +/** + * A service that calculates total and subtotals for orders, carts etc.. + * @implements BaseService + */ +class TotalsService extends BaseService { + constructor({ productVariantService }) { + super() + /** @private @const {ProductVariantService} */ + this.productVariantService_ = productVariantService + } + /** + * Calculates subtotal of a given cart + * @param {Cart} Cart - the cart to calculate subtotal for + * @return {int} the calculated subtotal + */ + getSubtotal(cart) { + let subtotal = 0 + if (!cart.items) { + return subtotal + } + + cart.items.map(item => { + if (Array.isArray(item.content)) { + const temp = _.sumBy(item.content, c => c.unit_price * c.quantity) + subtotal += temp * item.quantity + } else { + subtotal += + item.content.unit_price * item.content.quantity * item.quantity + } + }) + return subtotal + } + + /** + * Calculates either fixed or percentage discount of a variant + * @param {string} lineItem - id of line item + * @param {string} variant - id of variant in line item + * @param {int} variantPrice - price of the variant based on region + * @param {int} valye - discount value + * @param {string} discountType - the type of discount (fixed or percentage) + * @return {{ string, string, int }} triples of lineitem, variant and + * applied discount + */ + calculateDiscount_(lineItem, variant, variantPrice, value, discountType) { + if (discountType === "percentage") { + return { + lineItem, + variant, + amount: (variantPrice / 100) * value, + } + } else { + return { + lineItem, + variant, + amount: value >= variantPrice ? variantPrice : value, + } + } + } + + /** + * If the discount_rule of a discount has allocation="item", then we need + * to calculate discount on each item in the cart. Furthermore, we need to + * make sure to only apply the discount on valid variants. And finally we + * return ether an array of percentages discounts or fixed discounts + * alongside the variant on which the discount was applied. + * @param {Discount} discount - the discount to which we do the calculation + * @param {Cart} cart - the cart to calculate discounts for + * @return {[{ string, string, int }]} array of triples of lineitem, variant + * and applied discount + */ + async getAllocationItemDiscounts(discount, cart) { + const discounts = [] + for (const item of cart.items) { + if (discount.discount_rule.valid_for.length > 0) { + discount.discount_rule.valid_for.map(v => { + // Discounts do not apply to bundles, hence: + if (Array.isArray(item.content)) { + return discounts + } else { + if (item.content.variant._id === v) { + discounts.push( + this.calculateDiscount_( + item._id, + v, + item.content.unit_price, + discount.discount_rule.value, + discount.discount_rule.type + ) + ) + } + } + }) + } + } + return discounts + } + + /** + * Calculates discount total of a cart using the discounts provided in the + * cart.discounts array. This will be subtracted from the cart subtotal, + * which is returned from the function. + * @param {Cart} Cart - the cart to calculate discounts for + * @return {int} the subtotal after discounts are applied + */ + async getDiscountTotal(cart) { + let subtotal = this.getSubtotal(cart) + + if (!cart.discounts) { + return subtotal + } + + // filter out invalid discounts + cart.discounts = cart.discounts.filter(d => { + // !ends_at implies that the discount never expires + // therefore, we do the check following check + if (d.ends_at) { + const parsedEnd = new Date(d.ends_at) + const now = new Date() + return ( + parsedEnd.getTime() > now.getTime() && + d.regions.includes(cart.region_id) + ) + } else { + return d.regions && d.regions.includes(cart.region_id) + } + }) + + // we only support having free shipping and one other discount, so first + // find the discount, which is not free shipping. + const discount = cart.discounts.find( + ({ discount_rule }) => discount_rule.type !== "free_shipping" + ) + + if (!discount) { + return subtotal + } + + const { type, allocation, value } = discount.discount_rule + + if (type === "percentage" && allocation === "total") { + subtotal -= (subtotal / 100) * value + return subtotal + } + + if (type === "percentage" && allocation === "item") { + const itemPercentageDiscounts = await this.getAllocationItemDiscounts( + discount, + cart, + "percentage" + ) + const totalDiscount = _.sumBy(itemPercentageDiscounts, d => d.amount) + subtotal -= totalDiscount + return subtotal + } + + if (type === "fixed" && allocation === "total") { + subtotal -= value + return subtotal + } + + if (type === "fixed" && allocation === "item") { + const itemFixedDiscounts = await this.getAllocationItemDiscounts( + discount, + cart, + "fixed" + ) + const totalDiscount = _.sumBy(itemFixedDiscounts, d => d.amount) + subtotal -= totalDiscount + return subtotal + } + + return subtotal + } +} + +export default TotalsService