Adds dynamic coupon codes and segment plugin
This commit is contained in:
@@ -37,20 +37,6 @@ class KlarnaProviderService extends PaymentService {
|
||||
|
||||
async lineItemsToOrderLines_(cart, taxRate) {
|
||||
let order_lines = []
|
||||
// Find the discount, that is not free shipping
|
||||
const discount = cart.discounts.find(
|
||||
({ discount_rule }) => discount_rule.type !== "free_shipping"
|
||||
)
|
||||
|
||||
let itemDiscounts = []
|
||||
if (discount) {
|
||||
// If the discount has an item specific allocation method,
|
||||
// we need to fetch the discount for each item
|
||||
itemDiscounts = await this.totalsService_.getAllocationItemDiscounts(
|
||||
discount,
|
||||
cart
|
||||
)
|
||||
}
|
||||
|
||||
cart.items.forEach((item) => {
|
||||
// For bundles, we create an order line for each item in the bundle
|
||||
@@ -62,8 +48,6 @@ class KlarnaProviderService extends PaymentService {
|
||||
order_lines.push({
|
||||
name: item.title,
|
||||
unit_price: c.unit_price,
|
||||
// Medusa does not allow discount on bundles
|
||||
total_discount_amount: 0,
|
||||
quantity: c.quantity,
|
||||
tax_rate: taxRate * 10000,
|
||||
total_amount,
|
||||
@@ -71,17 +55,10 @@ class KlarnaProviderService extends PaymentService {
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// Find the discount for current item and default to 0
|
||||
const itemDiscount =
|
||||
(itemDiscounts &&
|
||||
itemDiscounts.find((el) => el.lineItem._id === item._id)) ||
|
||||
0
|
||||
|
||||
// Withdraw discount from the total item amount
|
||||
const quantity = item.quantity
|
||||
const unit_price = item.content.unit_price * 100 * (taxRate + 1)
|
||||
const total_discount_amount = itemDiscount * (taxRate + 1) * 100
|
||||
const total_amount = unit_price * quantity - total_discount_amount
|
||||
const total_amount = unit_price * quantity
|
||||
const total_tax_amount = total_amount * (taxRate / (1 + taxRate))
|
||||
|
||||
order_lines.push({
|
||||
@@ -89,7 +66,6 @@ class KlarnaProviderService extends PaymentService {
|
||||
tax_rate: taxRate * 10000,
|
||||
quantity,
|
||||
unit_price,
|
||||
total_discount_amount,
|
||||
total_amount,
|
||||
total_tax_amount,
|
||||
})
|
||||
@@ -127,6 +103,20 @@ class KlarnaProviderService extends PaymentService {
|
||||
|
||||
order.order_lines = await this.lineItemsToOrderLines_(cart, tax_rate)
|
||||
|
||||
const discount = (await this.totalsService_.getDiscountTotal(cart)) * 100
|
||||
if (discount) {
|
||||
order.order_lines.push({
|
||||
name: `Discount`,
|
||||
quantity: 1,
|
||||
type: "discount",
|
||||
unit_price: 0,
|
||||
total_discount_amount: discount * (1 + tax_rate),
|
||||
tax_rate: tax_rate * 10000,
|
||||
total_amount: - discount * (1 + tax_rate),
|
||||
total_tax_amount: - discount * tax_rate
|
||||
})
|
||||
}
|
||||
|
||||
if (!_.isEmpty(cart.billing_address)) {
|
||||
order.billing_address = {
|
||||
email: cart.email,
|
||||
@@ -145,7 +135,6 @@ class KlarnaProviderService extends PaymentService {
|
||||
// Defaults to Sweden
|
||||
order.purchase_country = "SE"
|
||||
}
|
||||
|
||||
order.order_amount = (await this.totalsService_.getTotal(cart)) * 100
|
||||
order.order_tax_amount = (await this.totalsService_.getTaxTotal(cart)) * 100
|
||||
// TODO: Check if currency matches ISO
|
||||
@@ -179,7 +168,6 @@ class KlarnaProviderService extends PaymentService {
|
||||
}
|
||||
|
||||
// If the cart does have shipping methods, set the selected shipping method
|
||||
|
||||
order.shipping_options = shippingOptions.map((so) => ({
|
||||
id: so._id,
|
||||
name: so.name,
|
||||
@@ -190,6 +178,7 @@ class KlarnaProviderService extends PaymentService {
|
||||
}))
|
||||
}
|
||||
|
||||
console.log(order)
|
||||
return order
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-transform-instanceof",
|
||||
"@babel/plugin-transform-classes"
|
||||
],
|
||||
"presets": ["@babel/preset-env"],
|
||||
"env": {
|
||||
"test": {
|
||||
"plugins": ["@babel/plugin-transform-runtime"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"plugins": ["prettier"],
|
||||
"extends": ["prettier"],
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"semi": "error",
|
||||
"no-unused-expressions": "true"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/lib
|
||||
node_modules
|
||||
.DS_store
|
||||
.env*
|
||||
/*.js
|
||||
!index.js
|
||||
!jest.config.js
|
||||
|
||||
/dist
|
||||
|
||||
/api
|
||||
/services
|
||||
/models
|
||||
/subscribers
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/lib
|
||||
node_modules
|
||||
.DS_store
|
||||
.env*
|
||||
/*.js
|
||||
!index.js
|
||||
yarn.lock
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "medusa-plugin-discount-generator",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"author": "Sebastian Rindom",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/medusa-plugin-discount-generator"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.7.5",
|
||||
"@babel/core": "^7.7.5",
|
||||
"@babel/node": "^7.7.4",
|
||||
"@babel/plugin-proposal-class-properties": "^7.7.4",
|
||||
"@babel/plugin-transform-instanceof": "^7.8.3",
|
||||
"@babel/plugin-transform-runtime": "^7.7.6",
|
||||
"@babel/preset-env": "^7.7.5",
|
||||
"@babel/register": "^7.7.4",
|
||||
"@babel/runtime": "^7.9.6",
|
||||
"client-sessions": "^0.8.0",
|
||||
"cross-env": "^5.2.1",
|
||||
"eslint": "^6.8.0",
|
||||
"jest": "^25.5.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "babel src -d .",
|
||||
"prepare": "cross-env NODE_ENV=production npm run build",
|
||||
"watch": "babel -w src --out-dir . --ignore **/__tests__",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"body-parser": "^1.19.0",
|
||||
"express": "^4.17.1",
|
||||
"medusa-core-utils": "^0.1.27",
|
||||
"randomatic": "^3.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Router } from "express"
|
||||
import bodyParser from "body-parser"
|
||||
import { Validator, MedusaError } from "medusa-core-utils"
|
||||
|
||||
export default (container) => {
|
||||
const route = Router()
|
||||
|
||||
route.post("/discount-code", bodyParser.json(), async (req, res) => {
|
||||
const schema = Validator.object().keys({
|
||||
discount_code: Validator.string().required(),
|
||||
})
|
||||
|
||||
const { value, error } = schema.validate(req.body)
|
||||
if (error) {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
|
||||
}
|
||||
|
||||
const discountGenerator = req.scope.resolve("discountGeneratorService")
|
||||
const code = await discountGenerator.generateDiscount(value.discount_code)
|
||||
|
||||
res.json({
|
||||
code
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
return route
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import randomize from "randomatic"
|
||||
import { BaseService } from "medusa-interfaces"
|
||||
|
||||
class DiscountGeneratorService extends BaseService {
|
||||
constructor({ discountService }) {
|
||||
super()
|
||||
|
||||
this.discountService_ = discountService
|
||||
}
|
||||
|
||||
async generateDiscount(dynamicDiscountCode) {
|
||||
const discount = await this.discountService_.retrieveByCode(dynamicDiscountCode)
|
||||
const data = {
|
||||
code: randomize("A0", 10)
|
||||
}
|
||||
return this.discountService_.createDynamicCode(discount._id, data)
|
||||
}
|
||||
}
|
||||
|
||||
export default DiscountGeneratorService
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-transform-instanceof",
|
||||
"@babel/plugin-transform-classes"
|
||||
],
|
||||
"presets": ["@babel/preset-env"],
|
||||
"env": {
|
||||
"test": {
|
||||
"plugins": ["@babel/plugin-transform-runtime"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"plugins": ["prettier"],
|
||||
"extends": ["prettier"],
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"semi": "error",
|
||||
"no-unused-expressions": "true"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/lib
|
||||
node_modules
|
||||
.DS_store
|
||||
.env*
|
||||
/*.js
|
||||
!index.js
|
||||
!jest.config.js
|
||||
|
||||
/dist
|
||||
|
||||
/api
|
||||
/services
|
||||
/models
|
||||
/subscribers
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/lib
|
||||
node_modules
|
||||
.DS_store
|
||||
.env*
|
||||
/*.js
|
||||
!index.js
|
||||
yarn.lock
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// noop
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "medusa-plugin-segment",
|
||||
"version": "0.3.0",
|
||||
"description": "Segment Analytics",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/medusa-plugin-segment"
|
||||
},
|
||||
"author": "Sebastian Rindom",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.7.5",
|
||||
"@babel/core": "^7.7.5",
|
||||
"@babel/node": "^7.7.4",
|
||||
"@babel/plugin-proposal-class-properties": "^7.7.4",
|
||||
"@babel/plugin-transform-classes": "^7.9.5",
|
||||
"@babel/plugin-transform-instanceof": "^7.8.3",
|
||||
"@babel/plugin-transform-runtime": "^7.7.6",
|
||||
"@babel/preset-env": "^7.7.5",
|
||||
"@babel/register": "^7.7.4",
|
||||
"@babel/runtime": "^7.9.6",
|
||||
"cross-env": "^5.2.1",
|
||||
"eslint": "^6.8.0",
|
||||
"jest": "^25.5.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "babel src -d .",
|
||||
"prepare": "cross-env NODE_ENV=production npm run build",
|
||||
"watch": "babel -w src --out-dir . --ignore **/__tests__",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"analytics-node": "^3.4.0-beta.1",
|
||||
"axios": "^0.19.2",
|
||||
"body-parser": "^1.19.0",
|
||||
"express": "^4.17.1",
|
||||
"medusa-core-utils": "^0.3.0",
|
||||
"medusa-interfaces": "^0.3.0",
|
||||
"medusa-test-utils": "^0.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Analytics from "analytics-node"
|
||||
import axios from "axios"
|
||||
import { BaseService } from "medusa-interfaces"
|
||||
|
||||
class SegmentService extends BaseService {
|
||||
/**
|
||||
* @param {Object} options - options defined in `medusa-config.js`
|
||||
* e.g.
|
||||
* {
|
||||
* write_key: Segment write key given in Segment dashboard
|
||||
* }
|
||||
*/
|
||||
constructor({totalsService}, options) {
|
||||
super()
|
||||
|
||||
this.options_ = options
|
||||
this.totalsService_ = totalsService
|
||||
|
||||
this.analytics_ = new Analytics(options.write_key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around segment's identify call
|
||||
*/
|
||||
identify(data) {
|
||||
return this.analytics_.identify(data)
|
||||
}
|
||||
|
||||
track(data) {
|
||||
return this.analytics_.track(data)
|
||||
}
|
||||
|
||||
async getReportingValue(fromCurrency, value) {
|
||||
const date = "latest"
|
||||
const toCurrency =
|
||||
(this.options_.reporting_currency &&
|
||||
this.options_.reporting_currency.toUpperCase()) ||
|
||||
"EUR"
|
||||
|
||||
if (fromCurrency === toCurrency) return value.toFixed(2)
|
||||
|
||||
const exchangeRate = await axios
|
||||
.get(`https://api.exchangeratesapi.io/${date}?symbols=${fromCurrency}&base=${toCurrency}`)
|
||||
.then(({ data }) => {
|
||||
return data.rates[fromCurrency]
|
||||
})
|
||||
|
||||
return (value / exchangeRate).toFixed(2)
|
||||
}
|
||||
|
||||
|
||||
async buildOrder(order) {
|
||||
console.log("build", order)
|
||||
const subtotal = await this.totalsService_.getSubtotal(order)
|
||||
const total = await this.totalsService_.getTotal(order)
|
||||
const tax = await this.totalsService_.getTaxTotal(order)
|
||||
const discount = await this.totalsService_.getDiscountTotal(order)
|
||||
const shipping = await this.totalsService_.getShippingTotal(order)
|
||||
const revenue = total - tax
|
||||
|
||||
let coupon
|
||||
if (order.discounts && order.discounts.length) {
|
||||
coupon = order.discounts[0].code
|
||||
}
|
||||
|
||||
const orderData = {
|
||||
checkout_id: order.cart_id,
|
||||
order_id: order._id,
|
||||
email: order.email,
|
||||
reporting_total: await this.getReportingValue(order.currency_code, total),
|
||||
reporting_subtotal: await this.getReportingValue(order.currency_code, subtotal),
|
||||
reporting_revenue: await this.getReportingValue(order.currency_code, revenue),
|
||||
reporting_shipping: await this.getReportingValue(order.currency_code, shipping),
|
||||
reporting_tax: await this.getReportingValue(order.currency_code, tax),
|
||||
reporting_discount: await this.getReportingValue(order.currency_code, discount),
|
||||
total,
|
||||
subtotal,
|
||||
revenue,
|
||||
shipping,
|
||||
tax,
|
||||
discount,
|
||||
coupon,
|
||||
currency: order.currency_code,
|
||||
products: await Promise.all(
|
||||
order.items.map(async item => {
|
||||
let name = item.title
|
||||
let variant = item.description
|
||||
|
||||
const unit_price = item.content.unit_price
|
||||
const line_total = unit_price * item.content.quantity * item.quantity
|
||||
const revenue = await this.getReportingValue(order.currency_code, line_total)
|
||||
return {
|
||||
name,
|
||||
variant,
|
||||
price: unit_price,
|
||||
reporting_revenue: revenue,
|
||||
product_id: `${item.content.product._id}`,
|
||||
sku: item.content.variant.sku,
|
||||
quantity: item.quantity,
|
||||
}
|
||||
})
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
return orderData
|
||||
}
|
||||
}
|
||||
|
||||
export default SegmentService
|
||||
@@ -0,0 +1,18 @@
|
||||
class OrderSubscriber {
|
||||
constructor({ segmentService, eventBusService }) {
|
||||
eventBusService.subscribe("order.placed", async (order) => {
|
||||
const date = new Date(parseInt(order.created))
|
||||
const orderData = await segmentService.buildOrder(order)
|
||||
const orderEvent = {
|
||||
event: "Order Completed",
|
||||
userId: order.customer_id,
|
||||
properties: orderData,
|
||||
timestamp: date,
|
||||
}
|
||||
|
||||
segmentService.track(orderEvent)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default OrderSubscriber
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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