Adds dynamic coupon codes and segment plugin
This commit is contained in:
@@ -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)
|
||||
}),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user