Adds dynamic coupon codes and segment plugin

This commit is contained in:
Sebastian Rindom
2020-07-17 14:06:55 +02:00
parent ec6a538877
commit 3710f7f80a
41 changed files with 11784 additions and 109 deletions
@@ -37,6 +37,7 @@ describe("POST /admin/discounts", () => {
value: 10,
allocation: "total",
},
is_dynamic: false
})
})
})
@@ -43,6 +43,7 @@ describe("POST /admin/discounts", () => {
value: 10,
allocation: "total",
},
is_dynamic: false,
}
)
})
@@ -3,6 +3,7 @@ import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const schema = Validator.object().keys({
code: Validator.string().required(),
is_dynamic: Validator.boolean().default(false),
discount_rule: Validator.object()
.keys({
description: Validator.string().optional(),
@@ -0,0 +1,25 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const { discount_id } = req.params
const schema = Validator.object().keys({
code: Validator.string().required(),
metadata: Validator.object().optional(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const discountService = req.scope.resolve("discountService")
await discountService.createDynamicCode(discount_id, value)
const data = await discountService.retrieve(dicsount_id)
res.status(200).json({ discount: data })
} catch (err) {
throw err
}
}
@@ -0,0 +1,21 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const { discount_id, code } = req.params
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const discountService = req.scope.resolve("discountService")
await discountService.deleteDynamicCode(discount_id, code)
const data = await discountService.retrieve(dicsount_id)
res.status(200).json({ discount: data })
} catch (err) {
throw err
}
}
@@ -22,6 +22,16 @@ export default app => {
middlewares.wrap(require("./delete-discount").default)
)
// Dynamic codes
route.post(
"/:discount_id/dynamic-codes",
middlewares.wrap(require("./create-dynamic-code").default)
)
route.delete(
"/:discount_id/dynamic-codes/:code",
middlewares.wrap(require("./delete-dynamic-code").default)
)
// Discount valid variants management
route.post(
"/:discount_id/variants/:variant_id",
@@ -4,6 +4,7 @@ export default async (req, res) => {
const { discount_id } = req.params
const schema = Validator.object().keys({
code: Validator.string().required(),
is_dynamic: Validator.boolean().default(false),
discount_rule: Validator.object()
.keys({
description: Validator.string().optional(),
@@ -13,6 +13,7 @@ export default async (req, res) => {
res.status(200).json({ cart })
} catch (err) {
console.log(err.response.data)
throw err
}
}
@@ -34,7 +34,6 @@ export default async (req, res) => {
res.status(200).json({ order })
} catch (err) {
console.log(err)
// If something fails it might be because the order has already been created
// if it has we find it from the cart id
const orderService = req.scope.resolve("orderService")
@@ -1,6 +1,17 @@
import { IdMap } from "medusa-test-utils"
export const discounts = {
dynamic: {
_id: IdMap.getId("dynamic"),
code: "Something",
is_dynamic: true,
discount_rule: {
type: "percentage",
allocation: "total",
value: 10,
},
regions: [IdMap.getId("region-france")],
},
total10Percent: {
_id: IdMap.getId("total10"),
code: "10%OFF",
@@ -117,6 +128,9 @@ export const DiscountModelMock = {
}),
deleteOne: jest.fn().mockReturnValue(Promise.resolve()),
findOne: jest.fn().mockImplementation(query => {
if (query._id === IdMap.getId("dynamic")) {
return Promise.resolve(discounts.dynamic)
}
if (query._id === IdMap.getId("total10")) {
return Promise.resolve(discounts.total10Percent)
}
@@ -0,0 +1,25 @@
import { IdMap } from "medusa-test-utils"
export const dynamicDiscounts = {
dynamicOff: {
_id: IdMap.getId("dynamicOff"),
discount_id: IdMap.getId("dynamic"),
code: "DYNAMICOFF",
disabled: false,
usage_count: 0,
},
}
export const DynamicDiscountCodeModelMock = {
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.code === "DYNAMICOFF") {
return Promise.resolve(dynamicDiscounts.dynamicOff)
}
return Promise.resolve(undefined)
}),
}
+1
View File
@@ -7,6 +7,7 @@ class DiscountModel extends BaseModel {
static schema = {
code: { type: String, required: true, unique: true },
is_dynamic: { type: Boolean, default: false },
discount_rule: { type: DiscountRule, required: true },
usage_count: { type: Number, default: 0 },
disabled: { type: Boolean, default: false },
@@ -0,0 +1,16 @@
import mongoose from "mongoose"
import { BaseModel } from "medusa-interfaces"
class DynamicDiscountCode extends BaseModel {
static modelName = "DynamicDiscountCode"
static schema = {
code: { type: String, required: true, unique: true },
discount_id: { type: String, required: true },
usage_count: { type: Number, default: 0 },
disabled: { type: Boolean, default: false },
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
}
}
export default DynamicDiscountCode
+1
View File
@@ -28,6 +28,7 @@ class OrderModel extends BaseModel {
customer_id: { type: String },
payment_method: { type: PaymentMethodSchema, required: true },
shipping_methods: { type: [ShippingMethodSchema], required: true },
created: { type: String, default: Date.now },
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
}
}
@@ -1,5 +1,9 @@
import DiscountService from "../discount"
import { DiscountModelMock, discounts } from "../../models/__mocks__/discount"
import {
DynamicDiscountCodeModelMock,
dynamicDiscounts,
} from "../../models/__mocks__/dynamic-discount-code"
import { IdMap } from "medusa-test-utils"
import { ProductVariantServiceMock } from "../__mocks__/product-variant"
import { RegionServiceMock } from "../__mocks__/region"
@@ -61,6 +65,46 @@ describe("DiscountService", () => {
})
})
describe("retrieveByCode", () => {
let res
const discountService = new DiscountService({
discountModel: DiscountModelMock,
dynamicDiscountCodeModel: DynamicDiscountCodeModelMock,
})
beforeEach(() => {
jest.clearAllMocks()
})
it("calls model layer findOne", async () => {
res = await discountService.retrieveByCode("10%off")
expect(DiscountModelMock.findOne).toHaveBeenCalledTimes(1)
expect(DiscountModelMock.findOne).toHaveBeenCalledWith({
code: "10%OFF",
})
expect(res).toEqual(discounts.total10Percent)
})
it("finds dynamic code", async () => {
res = await discountService.retrieveByCode("dynamicoff")
expect(DiscountModelMock.findOne).toHaveBeenCalledTimes(2)
expect(DiscountModelMock.findOne).toHaveBeenCalledWith({
_id: IdMap.getId("dynamic"),
})
expect(DiscountModelMock.findOne).toHaveBeenCalledWith({
code: "DYNAMICOFF",
})
expect(DynamicDiscountCodeModelMock.findOne).toHaveBeenCalledTimes(1)
expect(DynamicDiscountCodeModelMock.findOne).toHaveBeenCalledWith({
code: "DYNAMICOFF",
})
expect(res).toEqual({
...discounts.dynamic,
code: "DYNAMICOFF",
})
})
})
describe("update", () => {
const discountService = new DiscountService({
discountModel: DiscountModelMock,
+8
View File
@@ -610,6 +610,14 @@ class CartService extends BaseService {
async applyDiscount(cartId, discountCode) {
const cart = await this.retrieve(cartId)
const discount = await this.discountService_.retrieveByCode(discountCode)
if (discount.disabled) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"The discount code is disabled"
)
}
if (!discount.regions.includes(cart.region_id)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
+72 -5
View File
@@ -9,6 +9,7 @@ import _ from "lodash"
class DiscountService extends BaseService {
constructor({
discountModel,
dynamicDiscountCodeModel,
totalsService,
productVariantService,
regionService,
@@ -18,6 +19,9 @@ class DiscountService extends BaseService {
/** @private @const {DiscountModel} */
this.discountModel_ = discountModel
/** @private @const {DynamicDiscountCodeModel} */
this.dynamicCodeModel_ = dynamicDiscountCodeModel
/** @private @const {TotalsService} */
this.totalsService_ = totalsService
@@ -144,18 +148,38 @@ class DiscountService extends BaseService {
*/
async retrieveByCode(discountCode) {
discountCode = this.normalizeDiscountCode_(discountCode)
const discount = await this.discountModel_
let 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`
)
const dynamicCode = await this.dynamicCodeModel_
.findOne({ code: discountCode })
.catch(err => {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
if (!dynamicCode) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Discount with code ${discountCode} was not found`
)
}
discount = await this.retrieve(dynamicCode.discount_id)
if (!discount) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Discount with code ${discountCode} was not found`
)
}
discount.code = discountCode
discount.disabled = dynamicCode.disabled
}
return discount
}
@@ -186,6 +210,49 @@ class DiscountService extends BaseService {
)
}
/**
* Creates a dynamic code for a discount id.
* @param {string} discountId - the id of the discount to create a code for
* @param {string} code - the code to identify the discount by
* @return {Promise} the newly created dynamic code
*/
async createDynamicCode(discountId, data) {
const discount = await this.retrieve(discountId)
if (!discount.is_dynamic) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount must be set to dynamic"
)
}
const code = this.normalizeDiscountCode_(data.code)
return this.dynamicCodeModel_.create({
...data,
discount_id: discount._id,
code,
})
}
/**
* Creates a dynamic code for a discount id.
* @param {string} discountId - the id of the discount to create a code for
* @param {string} code - the code to identify the discount by
* @return {Promise} the newly created dynamic code
*/
async deleteDynamicCode(discountId, code) {
const discont = await this.retrieve(discountId)
if (!discount.is_dynamic) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount must be set to dynamic"
)
}
return this.dynamicCodeModel_.deleteOne({
code,
})
}
/**
* Adds a valid variant to the discount rule valid_for array.
* @param {string} discountId - id of discount
+76 -72
View File
@@ -245,89 +245,93 @@ class OrderService extends BaseService {
const dbSession = await this.orderModel_.startSession()
// Initialize DB transaction
return dbSession.withTransaction(async () => {
// Check if order from cart already exists
// If so, this function throws
const exists = await this.existsByCartId(cart._id)
if (exists) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Order from cart already exists"
return dbSession
.withTransaction(async () => {
// Check if order from cart already exists
// If so, this function throws
const exists = await this.existsByCartId(cart._id)
if (exists) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Order from cart already exists"
)
}
// Throw if payment method does not exist
if (!cart.payment_method) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Cart does not contain a payment method"
)
}
const { payment_method, payment_sessions } = cart
if (!payment_sessions || !payment_sessions.length) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"cart must have payment sessions"
)
}
let paymentSession = payment_sessions.find(
ps => ps.provider_id === payment_method.provider_id
)
}
// Throw if payment method does not exist
if (!cart.payment_method) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Cart does not contain a payment method"
// Throw if payment method does not exist
if (!paymentSession) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Cart does not have an authorized payment session"
)
}
const region = await this.regionService_.retrieve(cart.region_id)
const paymentProvider = this.paymentProviderService_.retrieveProvider(
paymentSession.provider_id
)
}
const { payment_method, payment_sessions } = cart
if (!payment_sessions || !payment_sessions.length) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"cart must have payment sessions"
const paymentStatus = await paymentProvider.getStatus(
paymentSession.data
)
}
let paymentSession = payment_sessions.find(
ps => ps.provider_id === payment_method.provider_id
)
// If payment status is not authorized, we throw
if (paymentStatus !== "authorized" && paymentStatus !== "succeeded") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Payment method is not authorized"
)
}
// Throw if payment method does not exist
if (!paymentSession) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Cart does not have an authorized payment session"
const paymentData = await paymentProvider.retrievePayment(
paymentSession.data
)
}
const region = await this.regionService_.retrieve(cart.region_id)
const paymentProvider = this.paymentProviderService_.retrieveProvider(
paymentSession.provider_id
)
const paymentStatus = await paymentProvider.getStatus(paymentSession.data)
const o = {
payment_method: {
provider_id: paymentSession.provider_id,
data: paymentData,
},
discounts: cart.discounts,
shipping_methods: cart.shipping_methods,
items: cart.items,
shipping_address: cart.shipping_address,
billing_address: cart.shipping_address,
region_id: cart.region_id,
email: cart.email,
customer_id: cart.customer_id,
cart_id: cart._id,
currency_code: region.currency_code,
}
// If payment status is not authorized, we throw
if (paymentStatus !== "authorized" && paymentStatus !== "succeeded") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Payment method is not authorized"
)
}
const orderDocument = await this.orderModel_.create([o], {
session: dbSession,
})
const paymentData = await paymentProvider.retrievePayment(
paymentSession.data
)
const o = {
payment_method: {
provider_id: paymentSession.provider_id,
data: paymentData,
},
discounts: cart.discounts,
shipping_methods: cart.shipping_methods,
items: cart.items,
shipping_address: cart.shipping_address,
billing_address: cart.shipping_address,
region_id: cart.region_id,
email: cart.email,
customer_id: cart.customer_id,
cart_id: cart._id,
currency_code: region.currency_code,
}
const orderDocument = await this.orderModel_.create([o], {
session: dbSession,
// Emit and return
this.eventBus_.emit(OrderService.Events.PLACED, orderDocument[0])
return orderDocument[0].toObject()
})
// Emit and return
this.eventBus_.emit(OrderService.Events.PLACED, orderDocument[0])
return orderDocument[0]
})
.then(() => this.orderModel_.findOne({ cart_id: cart._id }))
}
/**
+5 -4
View File
@@ -22,10 +22,10 @@ class TotalsService extends BaseService {
* @return {int} the calculated subtotal
*/
async getTotal(object) {
const subtotal = this.getSubtotal(object)
const subtotal = await this.getSubtotal(object)
const taxTotal = await this.getTaxTotal(object)
const discountTotal = await this.getDiscountTotal(object)
const shippingTotal = this.getShippingTotal(object)
const shippingTotal = await this.getShippingTotal(object)
return subtotal + taxTotal + shippingTotal - discountTotal
}
@@ -74,9 +74,10 @@ class TotalsService extends BaseService {
async getTaxTotal(object) {
const subtotal = this.getSubtotal(object)
const shippingTotal = this.getShippingTotal(object)
const discountTotal = await this.getDiscountTotal(object)
const region = await this.regionService_.retrieve(object.region_id)
const { tax_rate } = region
return (subtotal + shippingTotal) * tax_rate
return (subtotal - discountTotal + shippingTotal) * tax_rate
}
/**
@@ -260,7 +261,7 @@ class TotalsService extends BaseService {
async getDiscountTotal(cart) {
let subtotal = this.getSubtotal(cart)
if (!cart.discounts) {
if (!cart.discounts || !cart.discounts.length) {
return 0
}