UNPKG

@fabrix/spool-cart

Version:

Spool - eCommerce Spool for Fabrix

883 lines (882 loc) 36.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const common_1 = require("@fabrix/fabrix/dist/common"); const lodash_1 = require("lodash"); const shortid = require("shortid"); const moment = require("moment"); const errors_1 = require("@fabrix/spool-sequelize/dist/errors"); const enums_1 = require("../../enums"); const enums_2 = require("../../enums"); const enums_3 = require("../../enums"); class SubscriptionService extends common_1.FabrixService { publish(type, event, options = {}) { if (this.app.services.EventsService) { options.include = options.include || [{ model: this.app.models.EventItem.instance, as: 'objects' }]; return this.app.services.EventsService.publish(type, event, options); } this.app.log.debug('spool-events is not installed, please install it to use publish'); return Promise.resolve(); } generalStats() { const Subscription = this.app.models['Subscription']; let totalSubscriptions = 0; let totalActiveSubscriptions = 0; let totalDeactivatedSubscriptions = 0; let totalCancelledSubscriptions = 0; let totalActiveValue = 0; let totalDeactivatedValue = 0; let totalCancelledValue = 0; return Subscription.count() .then(total => { totalSubscriptions = total; return Subscription.count({ where: { active: true } }); }) .then(total => { totalActiveSubscriptions = total; return Subscription.count({ where: { active: false, cancelled: false } }); }) .then(total => { totalDeactivatedSubscriptions = total; return Subscription.count({ where: { cancelled: true } }); }) .then(total => { totalCancelledSubscriptions = total; return Subscription.sum('total_price', { where: { active: true } }); }) .then(total => { totalActiveValue = total; return Subscription.sum('total_price', { where: { cancelled: true } }); }) .then(total => { totalCancelledValue = total; return Subscription.sum('total_price', { where: { active: true, cancelled: false } }); }) .then(total => { totalDeactivatedValue = total; return { total: totalSubscriptions, total_active: totalActiveSubscriptions, total_deactivated: totalDeactivatedSubscriptions, total_cancelled: totalCancelledSubscriptions, total_active_value: totalActiveValue, total_deactivated_value: totalDeactivatedValue, total_cancelled_value: totalCancelledValue }; }); } create(order, items, unit, interval, active, options) { options = options || {}; const Subscription = this.app.models['Subscription']; items.forEach(item => { if (!(item instanceof this.app.models['OrderItem'].instance)) { throw new Error('Subscription item is not an instance of OrderItem'); } }); const resSubscription = Subscription.build({ original_order_id: order.id, customer_id: order.customer_id, email: order.email, line_items: items.map(item => { item = lodash_1.omit(item.get({ plain: true }), [ 'id', 'requires_subscription', 'subscription_unit', 'subscription_interval', 'fulfillment_id', 'fulfillment_status', 'order_id' ]); return item; }), unit: unit, interval: interval, active: active }); return resSubscription.save({ transaction: options.transaction || null }) .then(() => { return Subscription.sequelize.Promise.mapSeries(items, item => { item.subscription_id = resSubscription.id; return item.save({ transaction: options.transaction || null }); }); }) .then(() => { const event = { object_id: resSubscription.customer_id, object: 'customer', objects: [{ customer: resSubscription.customer_id }, { subscription: resSubscription.id }], type: 'customer.subscription.started', message: `Customer subscription ${resSubscription.token} started`, data: resSubscription }; return this.publish(event.type, event, { save: true, transaction: options.transaction || null }); }) .then(event => { return resSubscription; }); } update(update, subscription, options) { options = options || {}; const Subscription = this.app.models.Subscription; update = lodash_1.omit(update, ['id', 'created_at', 'updated_at']); let resSubscription; return Subscription.resolve(subscription, options) .then(_subscription => { if (!_subscription) { throw new Error('Subscription not found'); } resSubscription = _subscription; return resSubscription.update(update, { transaction: options.transaction || null }); }) .then(() => { const event = { object_id: resSubscription.customer_id, object: 'customer', objects: [{ customer: resSubscription.customer_id }, { subscription: resSubscription.id }], type: 'customer.subscription.updated', message: `Customer subscription ${resSubscription.token} updated`, data: resSubscription }; return this.publish(event.type, event, { save: true, transaction: options.transaction || null }); }) .then((event) => { return resSubscription.sendUpdatedEmail({ transaction: options.transaction || null }); }) .then((notifications) => { return resSubscription; }); } cancel(body, subscription, options) { options = options || {}; const Subscription = this.app.models['Subscription']; const Order = this.app.models['Order']; let resSubscription; return Subscription.resolve(subscription, options) .then(_subscription => { if (!_subscription) { throw new Error('Subscription not found'); } resSubscription = _subscription; resSubscription.cancel_reason = body.reason || enums_1.SUBSCRIPTION_CANCEL.OTHER; resSubscription.cancelled_at = new Date(); resSubscription.cancelled = true; resSubscription.active = false; return resSubscription.save({ transaction: options.transaction || null }); }) .then(() => { const event = { object_id: resSubscription.customer_id, object: 'customer', objects: [{ customer: resSubscription.customer_id }, { subscription: resSubscription.id }], type: 'customer.subscription.cancelled', message: `Customer subscription ${resSubscription.token} was cancelled`, data: resSubscription }; return this.publish(event.type, event, { save: true, transaction: options.transaction || null }); }) .then((event) => { if (body.cancel_pending) { return Order.findAll({ where: { customer_id: resSubscription.customer_id, subscription_token: resSubscription.token, financial_status: enums_3.ORDER_FINANCIAL.PENDING }, transaction: options.transaction || null }) .then(orders => { return Order.sequelize.Promise.mapSeries(orders, order => { return this.app.services.OrderService.cancel(order, { transaction: options.transaction || null }); }); }); } else { return; } }) .then((canceledOrders) => { return resSubscription.sendCancelledEmail({ transaction: options.transaction || null }); }) .then((notifications) => { return resSubscription; }); } activate(body, subscription, options) { options = options || {}; const Subscription = this.app.models['Subscription']; let resSubscription; return Subscription.resolve(subscription, options) .then(_subscription => { if (!_subscription) { throw new errors_1.ModelError('E_NOT_FOUND', 'Subscription Not Found'); } resSubscription = _subscription; return resSubscription.activate().save({ transaction: options.transaction || null }); }) .then(() => { const event = { object_id: resSubscription.customer_id, object: 'customer', objects: [{ customer: resSubscription.customer_id }, { subscription: resSubscription.id }], type: 'customer.subscription.activated', message: `Customer subscription ${resSubscription.token} was activated`, data: resSubscription }; return this.publish(event.type, event, { save: true, transaction: options.transaction || null }); }) .then((event) => { return resSubscription.sendActivateEmail({ transaction: options.transaction || null }); }) .then((notification) => { return resSubscription; }); } deactivate(body, subscription, options) { options = options || {}; const Subscription = this.app.models['Subscription']; let resSubscription; return Subscription.resolve(subscription, { transaction: options.transaction || null }) .then(_subscription => { if (!_subscription) { throw new errors_1.ModelError('E_NOT_FOUND', 'Subscription Not Found'); } resSubscription = _subscription; resSubscription.cancel_reason = null; resSubscription.cancelled_at = null; resSubscription.cancelled = false; resSubscription.active = false; return resSubscription.save({ transaction: options.transaction || null }); }) .then(() => { const event = { object_id: resSubscription.customer_id, object: 'customer', objects: [{ customer: resSubscription.customer_id }, { subscription: resSubscription.id }], type: 'customer.subscription.deactivated', message: `Customer subscription ${resSubscription.token} was deactivated`, data: resSubscription }; return this.publish(event.type, event, { save: true, transaction: options.transaction || null }); }) .then((event) => { return resSubscription.sendDeactivateEmail({ transaction: options.transaction || null }); }) .then(() => { return resSubscription; }); } addItems(items, subscription, options) { options = options || {}; const Subscription = this.app.models['Subscription']; if (items.line_items) { items = items.line_items; } let resSubscription; return Subscription.resolve(subscription, options) .then(_subscription => { if (!_subscription) { throw new errors_1.ModelError('E_NOT_FOUND', 'Subscription Not Found'); } resSubscription = _subscription; return Subscription.sequelize.Promise.mapSeries(items, item => { return this.app.services.ProductService.resolveItem(item, { transaction: options.transaction || null }); }); }) .then(resolvedItems => { return Subscription.sequelize.Promise.mapSeries(resolvedItems, (item, index) => { return resSubscription.addLine(item, items[index].quantity, items[index].properties, items[index].shop, { transaction: options.transaction || null }); }); }) .then(resolvedItems => { return resSubscription.save({ transaction: options.transaction || null }); }) .then(() => { const event = { object_id: resSubscription.customer_id, object: 'customer', objects: [{ customer: resSubscription.customer_id }, { subscription: resSubscription.id }], type: 'customer.subscription.items_added', message: `Customer subscription ${resSubscription.token} had items added`, data: resSubscription }; return this.publish(event.type, event, { save: true, transaction: options.transaction || null }); }) .then(event => { return resSubscription; }); } removeItems(items, subscription, options) { options = options || {}; const Subscription = this.app.models['Subscription']; if (items.line_items) { items = items.line_items; } let resSubscription; return Subscription.resolve(subscription, options) .then(_subscription => { if (!_subscription) { throw new errors_1.ModelError('E_NOT_FOUND', 'Subscription Not Found'); } resSubscription = _subscription; return Subscription.sequelize.Promise.mapSeries(items, item => { return this.app.services.ProductService.resolveItem(item, { transaction: options.transaction || null }); }); }) .then(resolvedItems => { return Subscription.sequelize.Promise.mapSeries(resolvedItems, (item, index) => { resSubscription.removeLine(item, items[index].quantity); }); }) .then(() => { return resSubscription.save({ transaction: options.transaction || null }); }) .then(() => { const event = { object_id: resSubscription.customer_id, object: 'customer', objects: [{ customer: resSubscription.customer_id }, { subscription: resSubscription.id }], type: 'customer.subscription.items_removed', message: `Customer subscription ${resSubscription.token} had items removed`, data: resSubscription }; return this.publish(event.type, event, { save: true, transaction: options.transaction || null }); }) .then(event => { return resSubscription; }); } renew(subscription, options) { options = options || {}; const Subscription = this.app.models['Subscription']; let resSubscription, resOrder, renewal; return Subscription.datastore.transaction(t => { options.transaction = t; return Subscription.resolve(subscription, { transaction: options.transaction || null }) .then(_subscription => { if (!_subscription) { throw new errors_1.ModelError('E_NOT_FOUND', 'Subscription Not Found'); } resSubscription = _subscription; return this.prepareForOrder(resSubscription, { transaction: options.transaction || null }); }) .then(newOrder => { return this.app.services.OrderService.create(newOrder, { transaction: options.transaction || null }); }) .then(_order => { if (!_order) { throw new Error(`Unexpected error during subscription ${resSubscription.id} renewal`); } if (!(_order instanceof this.app.models['Order'].instance)) { throw new Error('Did not return an instance of Order'); } resOrder = _order; resSubscription.last_order_id = resOrder.id; if (resOrder.financial_status === enums_3.ORDER_FINANCIAL.PAID) { renewal = 'success'; return resSubscription.renew() .save({ transaction: options.transaction || null }); } else { renewal = 'failure'; return resSubscription.retry() .save({ transaction: options.transaction || null }); } }) .then(newSubscription => { const event = { object_id: resSubscription.customer_id, object: 'customer', objects: [{ customer: resSubscription.customer_id }, { subscription: resSubscription.id }], type: `customer.subscription.renewed.${renewal}`, message: `Customer subscription ${resSubscription.token} renewal ${renewal}`, data: resSubscription }; return this.publish(event.type, event, { save: true, transaction: options.transaction || null }); }) .then((event) => { if (renewal === 'success') { return resSubscription.sendRenewedEmail({ transaction: options.transaction || null }); } else if (renewal === 'failure' && resSubscription.total_renewal_attempts === 1) { return resSubscription.sendFailedEmail({ transaction: options.transaction || null }); } else { return; } }) .then((notification) => { return { subscription: resSubscription, order: resOrder }; }); }); } retry(subscription, options) { options = options || {}; const Subscription = this.app.models['Subscription']; const Order = this.app.models['Order']; let resSubscription, resOrders, renewal; return Subscription.resolve(subscription, options) .then(_subscription => { if (!_subscription) { throw new errors_1.ModelError('E_NOT_FOUND', 'Subscription Not Found'); } if (!_subscription.token) { throw new Error('Subscription is missing token and can not be retried'); } resSubscription = _subscription; return Order.findAll({ where: { customer_id: resSubscription.customer_id, subscription_token: resSubscription.token, financial_status: enums_3.ORDER_FINANCIAL.PENDING }, transaction: options.transaction || null }); }) .then(_orders => { resOrders = _orders || []; if (resOrders.length === 0) { renewal = 'success'; return resSubscription.renew() .save({ transaction: options.transaction || null }); } else { renewal = 'failure'; return resSubscription.retry() .save({ transaction: options.transaction || null }); } }) .then(() => { const event = { object_id: resSubscription.customer_id, object: 'customer', objects: [{ customer: resSubscription.customer_id }, { subscription: resSubscription.id }], type: `customer.subscription.renewed.${renewal}`, message: `Customer subscription ${resSubscription.token} renewal ${renewal}`, data: resSubscription }; return this.publish(event.type, event, { save: true, transaction: options.transaction || null }); }) .then((event) => { if (renewal === 'success') { return resSubscription.sendRenewedEmail({ transaction: options.transaction || null }); } else { return; } }) .then((notifications) => { return resSubscription; }); } prepareForOrder(subscription, options) { options = options || {}; const Subscription = this.app.models['Subscription']; let resSubscription; return Subscription.resolve(subscription, { transaction: options.transaction || null }) .then(_subscription => { if (!_subscription) { throw new errors_1.ModelError('E_NOT_FOUND', 'Subscription Not Found'); } resSubscription = _subscription; return resSubscription.resolveCustomer({ transaction: options.transaction || null }); }) .then(() => { if (!resSubscription.Customer) { throw new errors_1.ModelError('E_NOT_FOUND', 'Subscription Customer Not Found'); } return resSubscription.Customer.resolveShippingAddress({ transaction: options.transaction || null }); }) .then(() => { return resSubscription.Customer.resolveBillingAddress({ transaction: options.transaction || null }); }) .then(() => { return resSubscription.Customer.getDefaultSource({ transaction: options.transaction || null }) .then(source => { if (!source) { return { payment_kind: 'immediate' || this.app.config.get('cart.orders.payment_kind'), transaction_kind: 'sale' || this.app.config.get('cart.orders.transaction_kind'), payment_details: [], fulfillment_kind: 'immediate' || this.app.config.get('cart.orders.fulfillment_kind') }; } else { return { payment_kind: 'immediate' || this.app.config.get('cart.orders.payment_kind'), transaction_kind: 'sale' || this.app.config.get('cart.orders.transaction_kind'), payment_details: [ { gateway: source.gateway, source: source, } ], fulfillment_kind: 'immediate' || this.app.config.get('cart.orders.fulfillment_kind') }; } }); }) .then(paymentDetails => { return resSubscription.buildOrder({ payment_details: paymentDetails.payment_details, transaction_kind: paymentDetails.transaction_kind || this.app.config.get('cart.orders.transaction_kind'), payment_kind: paymentDetails.payment_kind || this.app.config.get('cart.orders.payment_kind'), fulfillment_kind: paymentDetails.fulfillment_kind || this.app.config.get('cart.orders.fulfillment_kind'), processing_method: enums_2.PAYMENT_PROCESSING_METHOD.SUBSCRIPTION, shipping_address: resSubscription.Customer.shipping_address, billing_address: resSubscription.Customer.billing_address, customer_id: resSubscription.Customer.id, email: resSubscription.Customer.email }); }); } willRenew(subscription, options = {}) { const Subscription = this.app.models['Subscription']; let resSubscription; return Subscription.datastore.transaction(t => { options.transaction = t; return Subscription.resolve(subscription, { transaction: options.transaction || null }) .then(_subscription => { if (!_subscription) { throw new errors_1.ModelError('E_NOT_FOUND', 'Subscription Not Found'); } if (!(_subscription instanceof Subscription.instance)) { throw new Error('Subscription did not resolve instance of Subscription'); } resSubscription = _subscription; return resSubscription.willRenew().save({ transaction: options.transaction || null }); }) .then(() => { return resSubscription.sendWillRenewEmail({ transaction: options.transaction || null }); }) .then((notification) => { return resSubscription; }); }); } renewThisHour(options = {}) { const start = moment().startOf('hour'); const end = start.clone().endOf('hour'); const Subscription = this.app.models['Subscription']; const errors = []; let subscriptionsTotal = 0; this.app.log.debug('SubscriptionService.renewThisHour', start.format('YYYY-MM-DD HH:mm:ss'), end.format('YYYY-MM-DD HH:mm:ss')); return Subscription.batch({ where: { renews_on: { $gte: start.format('YYYY-MM-DD HH:mm:ss'), $lte: end.format('YYYY-MM-DD HH:mm:ss') }, active: true, total_renewal_attempts: 0 }, regressive: true, transaction: options.transaction || null }, (subscriptions) => { const Sequelize = Subscription.sequelize; return Sequelize.Promise.mapSeries(subscriptions, subscription => { return this.renew(subscription, { transaction: options.transaction || null }); }) .then(results => { subscriptionsTotal = subscriptionsTotal + results.length; return; }) .catch(err => { this.app.log.error(err); errors.push(err); return; }); }) .then(subscriptions => { const results = { subscriptions: subscriptionsTotal, errors: errors }; this.app.log.info(results); this.app.services.EventsService.publish('subscriptions.renew.complete', results); return results; }) .catch(err => { this.app.log.error(err); return; }); } retryThisHour(options) { options = options || {}; const Subscription = this.app.models['Subscription']; const start = moment().startOf('hour'); const errors = []; let subscriptionsTotal = 0; this.app.log.debug('SubscriptionService.retryThisHour', start.format('YYYY-MM-DD HH:mm:ss')); return Subscription.batch({ where: { renew_retry_at: { $or: { $lte: start.format('YYYY-MM-DD HH:mm:ss'), $eq: null } }, total_renewal_attempts: { $gt: 0, $lt: this.app.config.get('cart.subscriptions.retry_attempts') || 1 }, active: true }, regressive: true, transaction: options.transaction || null }, (subscriptions) => { const Sequelize = Subscription.sequelize; return Sequelize.Promise.mapSeries(subscriptions, subscription => { return this.retry(subscription, { transaction: options.transaction || null }); }) .then(results => { subscriptionsTotal = subscriptionsTotal + results.length; return; }) .catch(err => { this.app.log.error(err); errors.push(err); return; }); }) .then(subscriptions => { const results = { subscriptions: subscriptionsTotal, errors: errors }; this.app.log.info(results); this.app.services.EventsService.publish('subscriptions.retry.complete', results); return results; }) .catch(err => { this.app.log.error(err); return; }); } cancelThisHour(options) { options = options || {}; const Subscription = this.app.models['Subscription']; const errors = []; const start = moment().startOf('hour') .subtract(this.app.config.get('cart.subscriptions.grace_period_days') || 0, 'days'); let subscriptionsTotal = 0; this.app.log.debug('SubscriptionService.cancelThisHour', start.format('YYYY-MM-DD HH:mm:ss')); return Subscription.batch({ where: { $or: [ { total_renewal_attempts: { $gte: this.app.config.get('cart.subscriptions.retry_attempts') || 1 } }, { active: false } ], renews_on: { $gte: start.format('YYYY-MM-DD HH:mm:ss') }, cancelled: false }, regressive: true, transaction: options.transaction || null }, (subscriptions) => { const Sequelize = Subscription.sequelize; return Sequelize.Promise.mapSeries(subscriptions, subscription => { const reason = subscription.retry_attempts > 0 ? enums_1.SUBSCRIPTION_CANCEL.FUNDING : enums_1.SUBSCRIPTION_CANCEL.CUSTOMER; return this.cancel({ reason: reason, cancel_pending: true }, subscription, { transaction: options.transaction || null }); }) .then(results => { subscriptionsTotal = subscriptionsTotal + results.length; return; }) .catch(err => { this.app.log.error(err); errors.push(err); return; }); }) .then(subscriptions => { const results = { subscriptions: subscriptionsTotal, errors: errors }; this.app.log.info(results); this.app.services.EventsService.publish('subscriptions.cancel.complete', results); return results; }) .catch(err => { this.app.log.error(err); return; }); } willRenewDate(options = {}) { const start = moment() .add(this.app.config.get('cart.subscriptions.renewal_notice_days') || 0, 'days') .startOf('hour'); const end = start.clone() .endOf('hour'); const Subscription = this.app.models['Subscription']; const errors = []; let subscriptionsTotal = 0; this.app.log.debug('SubscriptionService.willRenewDate', start.format('YYYY-MM-DD HH:mm:ss'), end.format('YYYY-MM-DD HH:mm:ss')); return Subscription.batch({ where: { renews_on: { $gte: start.format('YYYY-MM-DD HH:mm:ss'), $lte: end.format('YYYY-MM-DD HH:mm:ss') }, notice_sent: false, active: true }, regressive: true, transaction: options.transaction || null }, (subscriptions) => { const Sequelize = Subscription.sequelize; return Sequelize.Promise.mapSeries(subscriptions, subscription => { return this.willRenew(subscription, { transaction: options.transaction || null }); }) .then(results => { subscriptionsTotal = subscriptionsTotal + results.length; return; }) .catch(err => { this.app.log.error(err); errors.push(err); return; }); }) .then(subscriptions => { const results = { subscriptions: subscriptionsTotal, errors: errors }; this.app.log.info(results); this.app.services.EventsService.publish('subscriptions.renew.complete', results); return results; }) .catch(err => { this.app.log.error(err); return; }); } beforeCreate(subscription, options = {}) { subscription.token = subscription.token || `subscription_${shortid.generate()}`; return this.app.models['Shop'].resolve(subscription.shop_id, { transaction: options.transaction || null }) .then(shop => { subscription.shop_id = shop.id; return subscription.recalculate({ transaction: options.transaction || null }); }) .catch(err => { return subscription.recalculate({ transaction: options.transaction || null }); }); } beforeUpdate(subscription, options = {}) { return subscription.recalculate({ transaction: options.transaction || null }); } afterCreate(subscription, options = {}) { this.app.services.EventsService.publish('subscription.created', subscription); return Promise.resolve(subscription); } afterUpdate(subscription, options = {}) { this.app.services.EventsService.publish('subscription.updated', subscription); return Promise.resolve(subscription); } } exports.SubscriptionService = SubscriptionService;