UNPKG

trailpack-proxy-cart

Version:

eCommerce - Trailpack for Proxy Engine

697 lines (669 loc) 24.3 kB
/* eslint new-cap: [0] */ /* eslint no-console: [0] */ 'use strict' const Model = require('trails/model') const Errors = require('proxy-engine-errors') const helpers = require('proxy-engine-helpers') const DISCOUNT_TYPES = require('../../lib').Enums.DISCOUNT_TYPES const DISCOUNT_STATUS = require('../../lib').Enums.DISCOUNT_STATUS const DISCOUNT_SCOPE = require('../../lib').Enums.DISCOUNT_SCOPE const _ = require('lodash') /** * @module Discount * @description Discount Model */ module.exports = class Discount extends Model { static config (app, Sequelize) { return { options: { underscored: true, enums: { DISCOUNT_TYPES: DISCOUNT_TYPES, DISCOUNT_STATUS: DISCOUNT_STATUS, DISCOUNT_SCOPE: DISCOUNT_SCOPE }, // defaultScope: { // where: { // live_mode: app.config.proxyEngine.live_mode // } // }, scopes: { live: { where: { live_mode: true } }, expired: () => { return { where: { ends_at: { $gte: new Date() } } } }, active: () => { return { where: { status: DISCOUNT_STATUS.ENABLED, starts_at: { $gte: new Date() }, ends_at: { $lte: new Date() } } } } }, hooks: { beforeValidate(values, options) { if (!values.handle && values.name) { values.handle = values.name } }, beforeCreate: function(values, options) { if (values.body) { const bodyDoc = app.services.RenderGenericService.renderSync(values.body) values.body_html = bodyDoc.document } }, beforeUpdate: function(values, options) { if (values.body) { const bodyDoc = app.services.RenderGenericService.renderSync(values.body) values.body_html = bodyDoc.document } } }, classMethods: { /** * Associate the Model * @param models */ associate: (models) => { // models.Cart.hasMany(models.Product, { // as: 'products' // }) models.Discount.belongsToMany(models.Order, { as: 'orders', through: { model: models.ItemDiscount, unique: false, scope: { model: 'order' } }, foreignKey: 'discount_id', constraints: false }) models.Discount.belongsToMany(models.Cart, { as: 'carts', through: { model: models.ItemDiscount, unique: false, scope: { model: 'cart' } }, foreignKey: 'discount_id', constraints: false }) models.Discount.belongsToMany(models.Product, { as: 'products', through: { model: models.ItemDiscount, unique: false, scope: { model: 'product' } }, foreignKey: 'discount_id', constraints: false }) models.Discount.belongsToMany(models.ProductVariant, { as: 'variants', through: { model: models.ItemDiscount, unique: false, scope: { model: 'productvariant' } }, foreignKey: 'discount_id', constraints: false }) models.Discount.belongsToMany(models.Customer, { as: 'customers', through: { model: models.ItemDiscount, unique: false, scope: { model: 'customer' } }, foreignKey: 'discount_id', constraints: false }) models.Discount.belongsToMany(models.Collection, { as: 'collections', through: { model: models.ItemDiscount, unique: false, scope: { model: 'collection' } }, foreignKey: 'discount_id', constraints: false }) models.Discount.hasMany(models.DiscountEvent, { as: 'discount_events', foreignKey: 'discount_id' }) }, /** * * @param options * @param batch * @returns Promise.<T> */ batch: function (options, batch) { const self = this options = options || {} options.limit = options.limit || 10 options.offset = options.offset || 0 options.regressive = options.regressive || false const recursiveQuery = function(options) { let count = 0 return self.findAndCountAll(options) .then(results => { count = results.count return batch(results.rows) }) .then(batched => { if (count >= (options.regressive ? options.limit : options.offset + options.limit)) { options.offset = options.regressive ? 0 : options.offset + options.limit return recursiveQuery(options) } else { return Promise.resolve() } }) } return recursiveQuery(options) }, /** * * @param discount * @param options * @returns {*} */ resolve: function(discount, options){ options = options || {} const Discount = this if (discount instanceof Discount){ return Promise.resolve(discount) } else if (discount && _.isObject(discount) && discount.id) { return Discount.findById(discount.id, options) .then(_discount => { if (!_discount) { throw new Errors.FoundError(Error(`Discount ${discount.id} not found`)) } return _discount }) } else if (discount && _.isObject(discount) && discount.handle) { return Discount.findOne( app.services.ProxyEngineService.mergeOptionDefaults( options, { where: { handle: discount.handle } } ) ) .then(_discount => { if (!_discount) { throw new Errors.FoundError(Error(`Discount ${discount.handle} not found`)) } return _discount }) } else if (discount && _.isObject(discount) && discount.code) { return Discount.findOne( app.services.ProxyEngineService.mergeOptionDefaults( options, { where: { code: discount.code } } ) ) .then(_discount => { if (!_discount) { throw new Errors.FoundError(Error(`Discount ${discount.code} not found`)) } return _discount }) } else if (discount && _.isNumber(discount)) { return Discount.findById(discount, options) .then(_discount => { if (!_discount) { throw new Errors.FoundError(Error(`Discount ${discount} not found`)) } return _discount }) } else if (discount && _.isString(discount)) { return Discount.findOne( app.services.ProxyEngineService.mergeOptionDefaults( options, { where: { code: discount } } ) ) .then(_discount => { if (!_discount) { throw new Errors.FoundError(Error(`Discount ${discount} not found`)) } return _discount }) } else { // TODO make Proper Error const err = new Error(`Not able to resolve discount ${discount}`) return Promise.reject(err) } }, /** * * @param discounts * @param options */ transformDiscounts: (discounts, options) => { options = options || {} discounts = discounts || [] const Discount = app.orm['Discount'] const Sequelize = Discount.sequelize // Transform if necessary to objects discounts = discounts.map(discount => { if (discount && _.isNumber(discount)) { return { id: discount } } else if (discount && _.isString(discount)) { return { handle: app.services.ProxyCartService.handle(discount), name: discount } } else if (discount && _.isObject(discount) && (discount.name || discount.handle)) { discount.handle = app.services.ProxyCartService.handle(discount.handle) || app.services.ProxyCartService.handle(discount.name) return discount } }) // Filter out undefined discounts = discounts.filter(discount => discount) return Sequelize.Promise.mapSeries(discounts, discount => { return Discount.findOne({ where: _.pick(discount, ['id','handle']), attributes: ['id', 'handle', 'name'], transaction: options.transaction || null }) .then(_discount => { if (_discount) { return _.extend(_discount, discount) } else { return app.services.DiscountService.create(discount, { transaction: options.transaction || null }) } }) }) } }, instanceMethods: { /** * * @returns {module:Discount} */ start: function() { this.status = DISCOUNT_STATUS.ENABLED return this }, /** * * @returns {module:Discount} */ stop: function() { this.status = DISCOUNT_STATUS.DISABLED return this }, /** * * @returns {module:Discount} */ depleted: function () { this.status = DISCOUNT_STATUS.DEPLETED return this }, /** * * @param orderId * @param customerId * @param price * @param options * @returns {Promise.<TResult>|*} */ logUsage: function (orderId, customerId, price, options) { this.times_used++ if (this.usage_limit > 0 && this.times_used >= this.usage_limit) { this.depleted() } return this.createDiscount_event({ customer_id: customerId, order_id: orderId, price: price }, { transaction: options.transaction || null }) .then(() => { return this.save({transaction: options.transaction || null}) }) }, /** * * @param customerId * @param options * @returns {Promise.<T>} */ eligibleCustomer: function(customerId, options) { return this.getDiscount_events({ where: { customer_id: customerId }, limit: 1, attributes: ['id','discount_id'], transaction: options.transaction || null }) .then(_previousUsages => { _previousUsages = _previousUsages || [] if (this.applies_once_per_customer && _previousUsages.length > 0) { return this } else { return true } }) .catch(err => { app.log.error(err) return }) }, /** * * @param item * @param criteria * @returns {*} */ discountItem: function(item, criteria) { criteria = criteria || [] app.log.debug('Discount.discountItem CRITERIA', criteria) // Set item defaults item.discounted_lines = item.discounted_lines || [] item.shipping_lines = item.shipping_lines || [] item.calculated_price = item.calculated_price || item.price item.total_discounts = item.total_discounts || 0 const discountedLine = { id: this.id, model: 'discount', type: null, name: this.name, scope: this.discount_scope, price: 0, applies: false, rules: { start: this.starts_at, end: this.ends_at, applies_once: this.applies_once, applies_once_per_customer: this.applies_once_per_customer, applies_compound: this.applies_compound, minimum_order_amount: this.minimum_order_amount } } let totalDeducted = 0 // If this discount is not enabled if (this.status !== DISCOUNT_STATUS.ENABLED) { return item } // If this has a usage limit and is past it's usage limit if (this.usage_limit > 0 && this.times_used > this.usage_limit) { return item } // If this item type is excluded from discount, ignore if ( this.discount_product_exclude.length > 0 && this.discount_product_exclude.indexOf(item.type) > -1 ) { return item } // If an item type is included in discount and it is not this item type, ignore if ( this.discount_product_include.length > 0 && this.discount_product_include.indexOf(item.type) === -1 ) { return item } // If this discount has already been applied if (item.discounted_lines && item.discounted_lines.some(discount => discount.id === this.id)) { return item } // If this discount is individual // If this discount only applies to individual products if (this.discount_scope === DISCOUNT_SCOPE.INDIVIDUAL) { const criteriaPair = criteria.find(d => d.discount === this.id) if (!criteriaPair) { return item } else if ( item.product_id && criteriaPair['product'] && criteriaPair['product'].indexOf(item.product_id) === -1) { return item } else if ( item.variant_id && criteriaPair['productvariant'] && criteriaPair['productvariant'].indexOf(item.variant_id) === -1) { return item } } // Set the type // If this is rate if (this.discount_type === DISCOUNT_TYPES.RATE) { discountedLine.rate = this.discount_rate discountedLine.type = DISCOUNT_TYPES.RATE discountedLine.price = discountedLine.rate } // If this is a threshold else if (this.discount_type === DISCOUNT_TYPES.THRESHOLD) { discountedLine.threshold = this.discount_threshold discountedLine.type = DISCOUNT_TYPES.THRESHOLD discountedLine.price = discountedLine.threshold } // If this is a percentage else if (this.discount_type === DISCOUNT_TYPES.PERCENTAGE) { discountedLine.percentage = this.discount_percentage discountedLine.type = DISCOUNT_TYPES.PERCENTAGE discountedLine.price = Math.round((item.price * (discountedLine.percentage / 100))) } // If this a shipping discount, return because this needs a different calculation else if (this.discount_type === DISCOUNT_TYPES.SHIPPING) { return item } // Set the total deducted totalDeducted = Math.min(item.price, (item.price - (item.price - discountedLine.price))) // If the totalDeducted is greater then zero, add the discount line. if (totalDeducted > 0) { // If this is a threshold discount, subtract from the threshold for the rest of this iteration if (discountedLine.type === DISCOUNT_TYPES.THRESHOLD) { this.discount_threshold = Math.max(0, this.discount_threshold - totalDeducted) } discountedLine.price = totalDeducted item.discounted_lines.push(discountedLine) } return item } } } } } static schema (app, Sequelize) { return { handle: { type: Sequelize.STRING, allowNull: false, unique: true, set: function(val) { this.setDataValue('handle', app.services.ProxyCartService.splitHandle(val) || null) } }, // The name of the discount name: { type: Sequelize.STRING }, // A description of the discount. description: { type: Sequelize.TEXT }, // The body of a collection (in markdown or html) body: { type: Sequelize.TEXT }, // The html of a collection (DO NOT EDIT DIRECTLY) body_html: { type: Sequelize.TEXT }, // The case-insensitive discount code that customers use at checkout. Required when creating a discount. Maximum length of 255 characters. code: { type: Sequelize.STRING, notNull: true }, // The scope of the discount price modifier for the collection (individual, global) discount_scope: { type: Sequelize.ENUM, values: _.values(DISCOUNT_SCOPE), defaultValue: DISCOUNT_SCOPE.INDIVIDUAL }, // Specify how the discount's value will be applied to the order. // Valid values are: rate, percentage, shipping discount_type: { type: Sequelize.ENUM, values: _.values(DISCOUNT_TYPES), defaultValue: DISCOUNT_TYPES.RATE }, // The value of the discount. See the discount_type property to learn more about how value is interpreted. discount_rate: { type: Sequelize.INTEGER }, // The value of the discount. See the discount_type property to learn more about how value is interpreted. discount_threshold: { type: Sequelize.INTEGER }, // The value of the discount. Required when creating a rate-based discount. See the discount_type property to learn more about how value is interpreted. discount_percentage: { type: Sequelize.FLOAT, defaultValue: 0.0 }, // The value of the discount. Required when creating a shipping-based discount. See the discount_type property to learn more about how value is interpreted. discount_shipping: { type: Sequelize.FLOAT, defaultValue: 0.0 }, // List of product types allowed to discount discount_product_include: helpers.JSONB('Discount', app, Sequelize, 'discount_product_include', { defaultValue: [] }), // List of product_type [<string>] to forcefully excluded from discount modifiers discount_product_exclude: helpers.JSONB('Discount', app, Sequelize, 'discount_product_exclude', { defaultValue: [] }), // List of customer types allowed to discount discount_customer_include: helpers.JSONB('Discount', app, Sequelize, 'discount_customer_include', { defaultValue: [] }), // List of customer_type [<string>] to forcefully excluded from discount modifiers discount_customer_exclude: helpers.JSONB('Discount', app, Sequelize, 'discount_customer_exclude', { defaultValue: [] }), // List of product_type [<string>] to forcefully excluded from shipping modifiers shipping_product_exclude: helpers.JSONB('Discount', app, Sequelize, 'shipping_product_exclude', { defaultValue: [] }), // List of product_type [<string>] to forcefully excluded from tax modifiers tax_product_exclude: helpers.JSONB('Discount', app, Sequelize, 'tax_product_exclude', { defaultValue: [] }), // The date when the discount code becomes disabled ends_at: { type: Sequelize.DATE }, // The date the discount becomes valid for use during checkout starts_at: { type: Sequelize.DATE }, // The status of the discount code. Valid values are enabled, disabled, or depleted. status: { type: Sequelize.ENUM, values: _.values(DISCOUNT_STATUS), defaultValue: DISCOUNT_STATUS.ENABLED }, // The minimum value an order must reach for the discount to be allowed during checkout. // Value of -1 or 0 is ignored minimum_order_amount: { type: Sequelize.INTEGER, defaultValue: 0 }, // The number of times this discount code can be redeemed. It can be redeemed by one or many customers; the usage_limit is a store-wide absolute value. Leave blank for unlimited uses. // Value of -1 or 0 equates to unlimited usage usage_limit: { type: Sequelize.INTEGER, defaultValue: 0 }, // Returns a count of successful checkouts where the discount code has been used. Cannot exceed the usage_limit property. times_used: { type: Sequelize.INTEGER, defaultValue: 0 }, // When a discount applies to a product or collection resource, applies_once determines whether the discount should be applied once per order, or to every applicable item in the cart. applies_once: { type: Sequelize.BOOLEAN, defaultValue: false, }, // Determines whether the discount should be applied once, or any number of times per customer. // Example, if true, then once the customer checks out, this discount can no longer apply to them // in subsequent orders. applies_once_per_customer: { type: Sequelize.BOOLEAN, defaultValue: false }, // if this discount can be compounded with other discounts. applies_compound: { type: Sequelize.BOOLEAN, defaultValue: true }, // Live Mode live_mode: { type: Sequelize.BOOLEAN, defaultValue: app.config.proxyEngine.live_mode } } } }