Adds DiscountService and TotalsService (#26)
This commit is contained in:
committed by
GitHub
parent
f6d5180e5f
commit
aff1ec3390
@@ -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)
|
||||
}),
|
||||
}
|
||||
|
||||
146
packages/medusa/src/models/__mocks__/discount.js
Normal file
146
packages/medusa/src/models/__mocks__/discount.js
Normal 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)
|
||||
}),
|
||||
}
|
||||
@@ -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: [] },
|
||||
|
||||
19
packages/medusa/src/models/discount.js
Normal file
19
packages/medusa/src/models/discount.js
Normal 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
|
||||
28
packages/medusa/src/models/schemas/discount-rule.js
Normal file
28
packages/medusa/src/models/schemas/discount-rule.js
Normal 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 },
|
||||
})
|
||||
@@ -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 = {
|
||||
|
||||
35
packages/medusa/src/services/__mocks__/discount.js
Normal file
35
packages/medusa/src/services/__mocks__/discount.js
Normal 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
|
||||
@@ -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")) {
|
||||
|
||||
16
packages/medusa/src/services/__mocks__/totals.js
Normal file
16
packages/medusa/src/services/__mocks__/totals.js
Normal 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
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
233
packages/medusa/src/services/__tests__/discount.js
Normal file
233
packages/medusa/src/services/__tests__/discount.js
Normal 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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
116
packages/medusa/src/services/__tests__/totals.js
Normal file
116
packages/medusa/src/services/__tests__/totals.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
300
packages/medusa/src/services/discount.js
Normal file
300
packages/medusa/src/services/discount.js
Normal 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
|
||||
179
packages/medusa/src/services/totals.js
Normal file
179
packages/medusa/src/services/totals.js
Normal 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
|
||||
Reference in New Issue
Block a user