@fabrix/spool-cart
Version:
Spool - eCommerce Spool for Fabrix
883 lines (882 loc) • 36.2 kB
JavaScript
"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;