Adds DiscountService and TotalsService (#26)

This commit is contained in:
Oliver Windall Juhl
2020-04-07 12:48:57 +02:00
committed by GitHub
parent f6d5180e5f
commit aff1ec3390
15 changed files with 1404 additions and 2 deletions

View File

@@ -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)
}),
}

View File

@@ -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)
}),
}

View File

@@ -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: [] },

View File

@@ -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

View File

@@ -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 },
})

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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")) {

View File

@@ -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

View File

@@ -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"
)
}
})
})
})

View File

@@ -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 }
)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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

View File

@@ -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<Discount>} 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<Discount>} 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

View File

@@ -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