UNPKG

trailpack-proxy-cart

Version:

eCommerce - Trailpack for Proxy Engine

592 lines (558 loc) 20.3 kB
/* eslint no-console: [0] */ 'use strict' const Service = require('trails/service') const _ = require('lodash') const Errors = require('proxy-engine-errors') const TRANSACTION_STATUS = require('../../lib').Enums.TRANSACTION_STATUS const TRANSACTION_KIND = require('../../lib').Enums.TRANSACTION_KIND const moment = require('moment') /** * @module TransactionService * @description Transaction Service */ module.exports = class TransactionService extends Service { /** * * @param transaction * @param options * @returns {transaction} */ create(transaction, options) { options = options || {} const Transaction = this.app.orm.Transaction return Transaction.create(transaction, options) } /** * * @param transaction * @param options * @returns {Promise.<transaction>} */ authorize(transaction, options) { options = options || {} const Transaction = this.app.orm['Transaction'] return Transaction.resolve(transaction, {transaction: options.transaction || null}) .then(_transaction => { if (!_transaction) { throw new Errors.FoundError(Error('Transaction not found')) } return this.app.services.PaymentService.authorize(_transaction, {transaction: options.transaction || null}) }) } /** * * @param transaction * @param options * @returns {Promise.<transaction>} */ capture(transaction, options) { options = options || {} const Transaction = this.app.orm['Transaction'] return Transaction.resolve(transaction, {transaction: options.transaction || null}) .then(_transaction => { if (!_transaction) { throw new Errors.FoundError(Error('Transaction not found')) } if (_transaction.kind !== TRANSACTION_KIND.AUTHORIZE) { throw new Errors.FoundError(Error(`Transaction must be first be ${TRANSACTION_KIND.AUTHORIZE} to ${TRANSACTION_KIND.CAPTURE}`)) } return this.app.services.PaymentService.capture(_transaction, {transaction: options.transaction || null}) }) } /** * * @param transaction * @param options * @returns {Promise.<transaction>} */ sale(transaction, options) { options = options || {} const Transaction = this.app.orm['Transaction'] return Transaction.resolve(transaction, {transaction: options.transaction || null}) .then(transaction => { if (!transaction) { throw new Errors.FoundError(Error('Transaction not found')) } return this.app.services.PaymentService.sale(transaction, {transaction: options.transaction || null}) }) } /** * * @param transaction * @param options * @returns {Promise.<transaction>} */ void(transaction, options) { options = options || {} const Transaction = this.app.orm['Transaction'] return Transaction.resolve(transaction, {transaction: options.transaction || null}) .then(_transaction => { if (!_transaction) { throw new Errors.FoundError(Error('Transaction not found')) } if (_transaction.status !== TRANSACTION_STATUS.SUCCESS) { throw new Error('Transaction must have successful to be refunded') } return this.app.services.PaymentService.void(_transaction, {transaction: options.transaction || null}) }) } /** * * @param transaction * @param amount * @param options * @returns {Promise.<T>} */ partiallyVoid(transaction, amount, options) { options = options || {} const Transaction = this.app.orm['Transaction'] let resTransaction return Transaction.resolve(transaction, {transaction: options.transaction || null}) .then(_transaction => { if (!_transaction) { throw new Errors.FoundError(Error('Transaction Not Found')) } if (_transaction.status !== TRANSACTION_STATUS.SUCCESS) { throw new Error('Transaction must have successful to be voided') } if (_transaction.kind !== TRANSACTION_KIND.AUTHORIZE) { throw new Error(`Transaction must be ${TRANSACTION_KIND.AUTHORIZE} to be partially voided`) } resTransaction = _transaction resTransaction.amount = Math.max(0, resTransaction.amount - amount) return resTransaction.save({transaction: options.transaction || null}) }) } /** * * @param transaction * @param options * @returns {Promise.<T>} */ refund(transaction, options) { options = options || {} const Transaction = this.app.orm['Transaction'] let resTransaction return Transaction.resolve(transaction, {transaction: options.transaction || null}) .then(_transaction => { if (!_transaction) { throw new Errors.FoundError(Error('Transaction not found')) } if (_transaction.status !== TRANSACTION_STATUS.SUCCESS) { throw new Error('Transaction must have been successful to be refunded') } if ([TRANSACTION_KIND.CAPTURE, TRANSACTION_KIND.SALE].indexOf(_transaction.kind) === -1) { throw new Error(`Only Transactions that are ${TRANSACTION_KIND.CAPTURE} or ${TRANSACTION_KIND.SALE} can be refunded`) } resTransaction = _transaction return this.app.services.PaymentService.refund(resTransaction, {transaction: options.transaction || null}) }) } /** * * @param transaction * @param amount * @param options * @returns {Promise.<T>} */ // TODO, double check if partial or full refund partiallyRefund(transaction, amount, options) { options = options || {} const Transaction = this.app.orm['Transaction'] let resTransaction return Transaction.resolve(transaction, {transaction: options.transaction || null}) .then(_transaction => { if (!_transaction) { throw new Errors.FoundError(Error('Transaction Not Found')) } if (!(_transaction instanceof Transaction)) { throw new Error('Transaction did not resolve an instance') } if (_transaction.status !== TRANSACTION_STATUS.SUCCESS) { throw new Error('Transaction must have been successful to be refunded') } if ([TRANSACTION_KIND.CAPTURE, TRANSACTION_KIND.SALE].indexOf(_transaction.kind) === -1) { throw new Error(`Only Transactions that are ${TRANSACTION_KIND.CAPTURE} or ${TRANSACTION_KIND.SALE} can be refunded`) } resTransaction = _transaction resTransaction.amount = Math.max(0, resTransaction.amount - amount) return resTransaction.save({transaction: options.transaction || null}) }) .then(() => { const newTransaction = _.omit(resTransaction.get({plain: true}), ['id','token']) newTransaction.amount = amount return this.create(newTransaction, {transaction: options.transaction || null}) }) .then(newTransaction => { return this.app.services.PaymentService.refund(newTransaction, {transaction: options.transaction || null}) }) } /** * * @param transaction * @param options * @returns {Promise.<T>} */ cancel(transaction, options) { options = options || {} const Transaction = this.app.orm['Transaction'] let resTransaction return Transaction.resolve(transaction, {transaction: options.transaction || null}) .then(_transaction => { if (!_transaction) { throw new Errors.FoundError(Error('Transaction Not Found')) } if (!(_transaction instanceof Transaction)) { throw new Error('Transaction did not resolve an instance') } if ([TRANSACTION_STATUS.PENDING, TRANSACTION_STATUS.FAILURE].indexOf(_transaction.status) === -1) { throw new Error('Transaction can not be cancelled if it is not pending or failed') } resTransaction = _transaction return this.app.services.PaymentService.cancel(resTransaction, {transaction: options.transaction || null}) }) } /** * Try a transaction or retry a transaction * @param transaction * @param options * @returns {Promise.<T>} */ retry(transaction, options) { options = options || {} const Transaction = this.app.orm['Transaction'] let resTransaction return Transaction.resolve(transaction, {transaction: options.transaction || null}) .then(_transaction => { if (!_transaction) { throw new Errors.FoundError(Error('Transaction Not Found')) } if (!(_transaction instanceof Transaction)) { throw new Error('Transaction did not resolve an instance') } if ([TRANSACTION_STATUS.PENDING, TRANSACTION_STATUS.FAILURE].indexOf(_transaction.status) === -1) { throw new Error('Transaction can not be tried if it is not pending or has not failed') } resTransaction = _transaction return this.app.services.PaymentService.retry(resTransaction, {transaction: options.transaction || null}) }) } reconcileCreate(order, amount, options) { options = options || {} const Order = this.app.orm['Order'] const Transaction = this.app.orm['Transaction'] const Customer = this.app.orm['Customer'] let resOrder, totalNew = 0, availablePending = [] return Order.resolve(order, {transaction: options.transaction || null}) .then(_order => { if (!_order) { throw new Errors.FoundError(Error('Order Not Found')) } if (!(_order instanceof Order)) { throw new Error('Order did not resolve an instance') } resOrder = _order return resOrder.resolveTransactions({transaction: options.transaction || null}) }) .then(() => { return resOrder.resolveCustomer({transaction: options.transaction || null}) }) .then(() => { totalNew = amount availablePending = resOrder.transactions.filter(transaction => transaction.status === TRANSACTION_STATUS.PENDING && [TRANSACTION_KIND.AUTHORIZE, TRANSACTION_KIND.SALE].indexOf(transaction.kind) > -1) // If a pending authorize or sale transaction just add the new amount total to one of the transactions if (availablePending.length > 0) { availablePending[0].amount = availablePending[0].amount + totalNew return availablePending[0].save({ hooks: false, transaction: options.transaction || null }) } else { // TODO get Source info if (!(resOrder.Customer instanceof Customer)) { return null } else { return resOrder.Customer.getDefaultSource({transaction: options.transaction || null}) .then(resSource => { if (!resSource) { throw new Error('Order Customer is missing a default source') } const transaction = Transaction.build({ // Set the customer id (in case we can save this source) customer_id: resOrder.customer_id, // Set the order id order_id: resOrder.id, // Set the source if it is given source_id: resSource ? resSource.id : null, // Set the order currency currency: resOrder.currency, // Set the amount for this transaction and handle if it is a split transaction amount: totalNew, // Copy the entire payment details to this transaction payment_details: { gateway: resSource ? resSource.gateway : null, source: resSource, }, // Specify the gateway to use gateway: resSource.gateway, // Set the device (that input the credit card) or null device_id: resSource ? resSource.device_id : null, // The kind of this new transaction kind: resOrder.payment_kind, // Set the Description description: `Order ${resOrder.name} original transaction ${resOrder.payment_kind}` }) // Process the transaction return this.app.services.PaymentService[resOrder.transaction_kind](transaction, { hooks: false, transaction: options.transaction || null }) }) .then(transaction => { // resOrder.addTransaction(transaction) has an updatedAt bug if (transaction) { const transactions = resOrder.transactions.concat(transaction) resOrder.transactions = transactions resOrder.setDataValue('transactions', transactions) resOrder.set('transactions', transactions) } return transaction }) } } }) .then(() => { return resOrder// .reload() }) } /** * * @param order * @param amount * @param options * @returns {Promise.<TResult>} */ reconcileUpdate(order, amount, options) { options = options || {} const Order = this.app.orm['Order'] let resOrder, totalNew = 0, availablePending = [], availableAuthorized = [], availableRefund = [], toUpdate = [] return Order.resolve(order, {transaction: options.transaction || null}) .then(_order => { if (!_order) { throw new Errors.FoundError(Error('Order Not Found')) } if (!(_order instanceof Order)) { throw new Error('Order did not resolve an instance') } resOrder = _order return resOrder.resolveTransactions({transaction: options.transaction || null}) }) .then(() => { totalNew = amount availablePending = resOrder.transactions.filter(transaction => transaction.status === TRANSACTION_STATUS.PENDING && [TRANSACTION_KIND.SALE, TRANSACTION_KIND.CAPTURE, TRANSACTION_KIND.AUTHORIZE].indexOf(transaction.kind) > -1) .sort((a, b) => { return b.amount - a.amount }) availableAuthorized = resOrder.transactions.filter(transaction => transaction.status === TRANSACTION_STATUS.SUCCESS && [TRANSACTION_KIND.AUTHORIZE].indexOf(transaction.kind) > -1) .sort((a, b) => { return b.amount - a.amount }) availableRefund = resOrder.transactions.filter(transaction => transaction.status === TRANSACTION_STATUS.SUCCESS && [TRANSACTION_KIND.CAPTURE, TRANSACTION_KIND.SALE].indexOf(transaction.kind) > -1) .sort((a, b) => { return b.amount - a.amount }) availablePending.forEach(transaction => { if (totalNew > 0) { const oldAmount = transaction.amount const newAmount = Math.max(0, transaction.amount - totalNew) totalNew = totalNew - oldAmount transaction.amount = newAmount toUpdate.push(transaction.save({ hooks: false, transaction: options.transaction || null })) } }) availableAuthorized.forEach(transaction => { if (totalNew > 0) { const oldAmount = transaction.amount const newAmount = Math.max(0, transaction.amount - totalNew) totalNew = totalNew - oldAmount toUpdate.push(this.partiallyVoid(transaction, oldAmount - newAmount, { hooks: false, transaction: options.transaction || null })) } }) availableRefund.forEach(transaction => { if (totalNew > 0) { const oldAmount = transaction.amount const newAmount = Math.max(0, transaction.amount - totalNew) totalNew = totalNew - oldAmount toUpdate.push(this.partiallyRefund(transaction, oldAmount - newAmount, { hooks: false, transaction: options.transaction || null })) } }) return Order.sequelize.Promise.mapSeries(toUpdate, update => { return update }) }) .then(() => { return resOrder }) } /** * * @returns {Promise.<TResult>} */ retryThisHour() { const Transaction = this.app.orm['Transaction'] const start = moment().startOf('hour') const errors = [] // let errorsTotal = 0 let transactionsTotal = 0 this.app.log.debug('TransactionService.retryThisHour', start.format('YYYY-MM-DD HH:mm:ss')) return Transaction.batch({ where: { retry_at: { $or: { $lte: start.format('YYYY-MM-DD HH:mm:ss'), $eq: null } }, total_retry_attempts: { $gte: 0, $lte: this.app.config.get('proxyCart.transactions.retry_attempts') || 1 }, status: TRANSACTION_STATUS.FAILURE }, regressive: true }, (transactions) => { const Sequelize = Transaction.sequelize return Sequelize.Promise.mapSeries(transactions, transaction => { return this.retry(transaction) }) .then(results => { // Calculate Totals transactionsTotal = transactionsTotal + results.length return }) .catch(err => { // errorsTotal++ this.app.log.error(err) errors.push(err) return }) }) .then(transactions => { const results = { transactions: transactionsTotal, errors: errors } this.app.log.info(results) this.app.services.ProxyEngineService.publish('transactions.retry.complete', results) return results }) .catch(err => { this.app.log.error(err) return }) } /** * * @returns {Promise.<TResult>} */ cancelThisHour() { const Transaction = this.app.orm['Transaction'] const errors = [] const start = moment().startOf('hour') .subtract(this.app.config.get('proxyCart.transactions.authorization_exp_days') || 0, 'days') // let errorsTotal = 0 let transactionsTotal = 0 this.app.log.debug('TransactionService.cancelThisHour', start.format('YYYY-MM-DD HH:mm:ss')) return Transaction.batch({ where: { authorization_exp: { $gte: start.format('YYYY-MM-DD HH:mm:ss') }, total_retry_attempts: { $gte: this.app.config.get('proxyCart.transactions.retry_attempts') || 1 }, status: TRANSACTION_STATUS.FAILURE }, regressive: true }, (transactions) => { const Sequelize = Transaction.sequelize return Sequelize.Promise.mapSeries(transactions, transaction => { return this.cancel(transaction) }) .then(results => { // Calculate Totals transactionsTotal = transactionsTotal + results.length return }) .catch(err => { // errorsTotal++ this.app.log.error(err) errors.push(err) return }) }) .then(transactions => { const results = { transactions: transactionsTotal, errors: errors } this.app.log.info(results) this.app.services.ProxyEngineService.publish('transactions.cancel.complete', results) return results }) .catch(err => { this.app.log.error(err) return }) } /** * * @param transaction * @param options * @returns {Promise.<transaction>} */ afterCreate(transaction, options) { options = options || {} return transaction.reconcileOrderFinancialStatus(options) .catch(err => { this.app.log.error(err) return transaction }) } /** * * @param transaction * @param options * @returns {Promise.<transaction>} */ afterUpdate(transaction, options) { options = options || {} return transaction.reconcileOrderFinancialStatus(options) .catch(err => { this.app.log.error(err) return transaction }) } }