diff --git a/constants/constants.js b/constants/constants.js index 8f6f966..9ccad10 100644 --- a/constants/constants.js +++ b/constants/constants.js @@ -66,6 +66,8 @@ const incidentType = { UNSCHEDULED_INCIDENT_AFTER_RESERVATION: 4, UNLOCKED_INCIDENT_STANDALONE: 5, UNSCHEDULED_INCIDENT_STANDALONE: 6, + BOOKING_MOVED_TO_ANOTHER_DAY: 7, + BOOKING_SHORTENED: 8, }; const UI_TIMEZONE = process.env.UI_TIMEZONE || 'America/Los_Angeles'; @@ -76,6 +78,8 @@ const MAX_BACK_TO_BACK_DIFFERENCE = parseInt(process.env.MAX_BACK_TO_BACK_DIFFER const UNSCHEDULED_TIME_RESOLUTION = parseInt(process.env.UNSCHEDULED_USE_TIME_RESOLUTION) || 5; const UNSCHEDULED_CHARGE_PRICE = parseFloat(process.env.UNSCHEDULED_USE_CHARGE_PRICE) || 5; +const BOOKING_CHANGE_PERCENTAGE_CHARGE = parseInt(process.env.BOOKING_CHANGE_PERCENTAGE_CHARGE) || 100; + module.exports = { VALID_CSV_HEADERS, USER_ENTRY_EVENT, @@ -92,4 +96,5 @@ module.exports = { MAX_BACK_TO_BACK_DIFFERENCE, UNSCHEDULED_TIME_RESOLUTION, UNSCHEDULED_CHARGE_PRICE, + BOOKING_CHANGE_PERCENTAGE_CHARGE, }; diff --git a/cronServices/checkBookingChanges.js b/cronServices/checkBookingChanges.js index a3befcc..00451a3 100644 --- a/cronServices/checkBookingChanges.js +++ b/cronServices/checkBookingChanges.js @@ -1,23 +1,32 @@ 'use strict'; - require('dotenv').config(); const { fetchAllBookings, bulkWriteReservationsWithChangesTracking } = require('../services/officeRnD/bookings'); +const { chargeBookingChanges } = require('../services/integration/bookingChangeCharges'); + const checkBookingChanges = () => { fetchAllBookings() .then((reservations) => { bulkWriteReservationsWithChangesTracking(reservations) .then((changes) => { - console.log('== CHANGES == '); - console.log(changes); + chargeBookingChanges(changes) + .then(() => { + process.exit(); + }) + .catch((error) => { + console.log('Error creating charges ', error); + process.exit(); + }); }) .catch((error) => { console.log('Error bulk write booking reservations :', error); + process.exit(); }); }) .catch((error) => { console.log('Error fetching bookings from ORD ', error); + process.exit(); }); }; diff --git a/environment.env b/environment.env index f9172c0..1ea3b01 100644 --- a/environment.env +++ b/environment.env @@ -18,3 +18,5 @@ UNLOCK_4=Price for unlocked door, fifth month UNLOCK_5=Price for unlocked door, sixth month UNLOCK_STREAK_REPAIR_AFTER=Number of months without incidents to reset user incident level + +BOOKING_CHANGE_PERCENTAGE_CHARGE=Percentage of hourly reate to apply for cancellation-like charges diff --git a/migrations/20190708114639-create-booking-change-incidents-table.js b/migrations/20190708114639-create-booking-change-incidents-table.js new file mode 100644 index 0000000..8c6e382 --- /dev/null +++ b/migrations/20190708114639-create-booking-change-incidents-table.js @@ -0,0 +1,34 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('bookingChangeIncidents', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + reservationId: Sequelize.TEXT, + memberId: Sequelize.TEXT, + resourceId: Sequelize.TEXT, + oldBookingStart: Sequelize.DATE, + oldBookingEnd: Sequelize.DATE, + newBookingStart: Sequelize.DATE, + newBookingEnd: Sequelize.DATE, + incidentType: Sequelize.INTEGER, + chargeFee: Sequelize.FLOAT, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('bookingChangeIncidents'); + } +}; diff --git a/migrations/20190708120732-add-reservation-hourly-price-column-to-the-booking-reservations-table.js b/migrations/20190708120732-add-reservation-hourly-price-column-to-the-booking-reservations-table.js new file mode 100644 index 0000000..1cd1aca --- /dev/null +++ b/migrations/20190708120732-add-reservation-hourly-price-column-to-the-booking-reservations-table.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.addColumn('bookingReservations', 'hourlyRate', { + type: Sequelize.FLOAT, + after: 'end', + }); + }, + + down: (queryInterface, Sequelize) => { + return queryInterface.removeColumn('bookingReservations', 'hourlyRate'); + } +}; diff --git a/models/bookingChangeIncident.js b/models/bookingChangeIncident.js new file mode 100644 index 0000000..1a91f1e --- /dev/null +++ b/models/bookingChangeIncident.js @@ -0,0 +1,19 @@ +'use strict'; + +module.exports = (sequelize, DataTypes) => { + const bookingChangeIncident = sequelize.define('bookingChangeIncident', { + reservationId: DataTypes.TEXT, + memberId: DataTypes.TEXT, + resourceId: DataTypes.TEXT, + oldBookingStart: DataTypes.DATE, + oldBookingEnd: DataTypes.DATE, + newBookingStart: DataTypes.DATE, + newBookingEnd: DataTypes.DATE, + incidentType: DataTypes.INTEGER, + chargeFee: DataTypes.FLOAT, + }, {}); + bookingChangeIncident.associate = function(models) { + // associations can be defined here + }; + return bookingChangeIncident; +}; diff --git a/models/bookingReservation.js b/models/bookingReservation.js index 9d13ba5..d6ba4c7 100644 --- a/models/bookingReservation.js +++ b/models/bookingReservation.js @@ -11,6 +11,7 @@ module.exports = (sequelize, DataTypes) => { resourceId: DataTypes.TEXT, start: DataTypes.DATE, end: DataTypes.DATE, + hourlyRate: DataTypes.FLOAT, timezone: DataTypes.TEXT, canceled: DataTypes.BOOLEAN, }, {}); diff --git a/services/integration/bookingChangeCharges.js b/services/integration/bookingChangeCharges.js index eb109ab..13d47b2 100644 --- a/services/integration/bookingChangeCharges.js +++ b/services/integration/bookingChangeCharges.js @@ -1,2 +1,115 @@ 'use strict'; +const moment = require('moment-timezone'); +const db = require('../../models/index'); + +const { UI_TIMEZONE, BOOKING_CHANGE_PERCENTAGE_CHARGE, incidentType } = require('../../constants/constants'); + +const bulkWriteBookingChangeIncidents = (incidents) => { + return new Promise((resolve, reject) => { + const asyncJobs = []; + incidents.forEach((incident) => { + asyncJobs.push(db.bookingChangeIncident.findOrCreate({where: incident, defaults: incident})); + }); + + Promise.all(asyncJobs) + .then(() => { + resolve(); + }) + .catch((error) => reject(error)); + }); +}; + +const chargeBookingChanges = (changes) => { + return new Promise((resolve, reject) => { + if (Array.isArray(changes)){ + const incidents = []; + const errors = []; + changes.forEach((change) => { + const { oldReservation, newReservation } = change; + if (oldReservation && newReservation){ + const oldStart = oldReservation.start ? moment.utc(oldReservation.start) : null; + const oldEnd = oldReservation.end ? moment.utc(oldReservation.end) : null; + + const newStart = newReservation.start ? moment.utc(newReservation.start) : null; + const newEnd = newReservation.end ? moment.utc(newReservation.end) : null; + + const reservationTimezone = newReservation.timezone ? newReservation.timezone : UI_TIMEZONE; + const reservationHourlyRate = newReservation.hourlyRate ? newReservation.hourlyRate : undefined; + + if (oldStart && oldEnd && newStart && newEnd && reservationHourlyRate){ + const oldReservationLength = oldEnd.diff(oldStart, 'hours', true); + const newReservationLength = newEnd.diff(newStart, 'hours', true); + + const differenceFromNow = oldStart.diff(moment.utc(), 'hours'); + + if (differenceFromNow && (differenceFromNow < 24)){ + // Changed reservation that was within 24hrs from now + + // Check if new reservation is on same day + const sameDay = oldStart.tz(reservationTimezone).isSame(newStart.tz(reservationTimezone), 'day'); + + const { reservationId, memberId, resourceId, hourlyRate } = newReservation; + + if (sameDay){ + // Reservation moved in same day + // Check if member shortened the reservation + + if (newReservationLength < oldReservationLength){ + + const differenceInLength = oldReservationLength - newReservationLength; + const chargeFee = differenceInLength*hourlyRate*BOOKING_CHANGE_PERCENTAGE_CHARGE/100; + + const incident = { + reservationId, + memberId, + resourceId, + oldBookingStart: oldReservation.start, + oldBookingEnd: oldReservation.end, + newBookingStart: newReservation.start, + newBookingEnd: newReservation.end, + incidentType: incidentType.BOOKING_SHORTENED, + chargeFee, + }; + + incidents.push(incident); + } + }else{ + // Reservation moved to another day + // Add cancellation charge + const chargeFee = oldReservationLength*hourlyRate*BOOKING_CHANGE_PERCENTAGE_CHARGE/100; + + const incident = { + reservationId, + memberId, + resourceId, + oldBookingStart: oldReservation.start, + oldBookingEnd: oldReservation.end, + newBookingStart: newReservation.start, + newBookingEnd: newReservation.end, + incidentType: incidentType.BOOKING_MOVED_TO_ANOTHER_DAY, + chargeFee, + }; + + incidents.push(incident); + } + } + }else{ + errors.push(change); + } + } + }); + if (errors.length > 0){ + console.log('There were some errors with incomplete bookings : '); + console.log(errors); + } + resolve(bulkWriteBookingChangeIncidents(incidents)); + }else{ + reject('Input argument is not an array !'); + } + }); +}; + +module.exports = { + chargeBookingChanges, +}; diff --git a/services/officeRnD/bookings.js b/services/officeRnD/bookings.js index 11fe373..3a63a6c 100644 --- a/services/officeRnD/bookings.js +++ b/services/officeRnD/bookings.js @@ -15,6 +15,10 @@ const fetchAllBookings = () => { const bookingData = result && result.data ? result.data : []; bookingData.forEach((fullBookingEntry) => { + const fees = fullBookingEntry && fullBookingEntry.fees ? fullBookingEntry.fees : []; + const firstFee = fees.length > 0 && fees[0].fee ? fees[0].fee : undefined; + const hourlyRate = firstFee && firstFee.price ? firstFee.price : undefined; + cleanedBookingReservations.push({ reservationId: fullBookingEntry['_id'], memberId: fullBookingEntry.member, @@ -24,6 +28,7 @@ const fetchAllBookings = () => { end: fullBookingEntry.end.dateTime, timezone: fullBookingEntry.timezone, canceled: fullBookingEntry.canceled || false, + hourlyRate, }); }); resolve(cleanedBookingReservations); @@ -145,7 +150,19 @@ const getFirstReservationInBlock = (reservation) => { }; const writeBookingReservation = (bookingReservation) => { - return db.bookingReservation.findOrCreate({where: {...bookingReservation}, defaults: {...bookingReservation}}); + const { reservationId, memberId, officeId, resourceId, start, end, timezone, canceled, hourlyRate } = bookingReservation; + const bookingReservationForDB = { + reservationId, + memberId, + officeId, + resourceId, + start, + end, + timezone, + canceled, + hourlyRate, + }; + return db.bookingReservation.findOrCreate({where: {...bookingReservationForDB}, defaults: {...bookingReservationForDB}}); }; const bulkWriteReservationsWithChangesTracking = (reservations) => { @@ -157,28 +174,21 @@ const bulkWriteReservationsWithChangesTracking = (reservations) => { const changedKeys = instance.changed(); const previous = instance.previous(); - const indexOfUpdatedAt = changedKeys.indexOf('updatedAt'); + const lookupKeys = ['start', 'end']; - if (indexOfUpdatedAt !== -1){ - changedKeys.splice(indexOfUpdatedAt, 1); - } - - if (changedKeys.length > 0){ - //check if there is really difference, reservation start and end timestamps are reported as changed but they are not - - let realChange = false; - changedKeys.forEach((changedKey) => { - if (JSON.stringify(previous[changedKey]) !== JSON.stringify(instance[changedKey])){ + let realChange = false; + lookupKeys.forEach((key) => { + if ((changedKeys.indexOf(key) !== -1) && + (JSON.stringify(previous[key]) !== JSON.stringify(instance[key]))){ realChange = true; - } - }); - - if (realChange){ - changes.push({ - oldReservation: previous, - newReservation: instance.get(), - }); } + }); + + if (realChange){ + changes.push({ + oldReservation: previous, + newReservation: instance.get(), + }); } });