occasion-sdk
Version:
An SDK library that enables access to Occasion's application, providing a rich DSL for creating and managing bookings.
799 lines (658 loc) • 22.9 kB
JavaScript
ActiveResource.Interfaces.JsonApi.contentType = 'application/json';
class Occasion {
static baseUrl = 'https://occ.sn/api/v1';
static Client(options = {}) {
var url = options.baseUrl || Occasion.baseUrl;
var token = options.token;
var immutable = options.immutable || false;
if(!_.isString(token)) {
throw 'Token must be of type string';
}
var encodedToken = window.btoa(unescape(encodeURIComponent(token + ':')));
var libraryOptions = {
headers: {
Authorization: "Basic " + encodedToken
},
immutable,
strictAttributes: true
};
var resourceLibrary =
ActiveResource.createResourceLibrary(url, libraryOptions);
Occasion.Modules.each(function(initializeModule) { initializeModule(resourceLibrary) });
return resourceLibrary;
}
}
Occasion.Modules = ActiveResource.prototype.Collection.build();
// @todo Remove includes({ product: 'merchant' }) when AR supports owner assignment to has_many children
// in non-load queries
Occasion.__constructCalendar = function __constructCalendar(month, {
calendar,
monthlyTimeSlotDaysBatchSize,
monthlyTimeSlotPreloadSize,
preload,
prevPagePromise,
relation,
status,
timeZone
} = {}) {
var today = moment.tz(timeZone);
status = status || 'bookable';
var lowerRange;
if(month) {
lowerRange = month.tz(timeZone);
} else {
lowerRange = today;
}
lowerRange = lowerRange.startOf('month');
var upperRange = lowerRange.clone().endOf('month');
var numRequests = Math.ceil(upperRange.diff(lowerRange, 'days') / monthlyTimeSlotDaysBatchSize);
if(numRequests < 1) numRequests = 1;
var i = 0;
var requests = [];
var lower = lowerRange.clone();
var upper = lowerRange.clone().add(monthlyTimeSlotDaysBatchSize, 'days');
while(i < numRequests) {
if(i + 1 == numRequests) upper = upperRange.clone();
requests.push(
relation
.includes({
product: 'merchant'
})
.where({
startsAt: {
ge: lower.toDate(),
le: upper.toDate()
},
status
}).all()
);
lower.add(monthlyTimeSlotDaysBatchSize, 'days');
upper.add(monthlyTimeSlotDaysBatchSize, 'days');
i++;
}
calendar = calendar || {};
if(_.isUndefined(calendar.__currentPage)) calendar.__currentPage = 0;
if(_.isUndefined(calendar.__preloadedPages)) calendar.__preloadedPages = 0;
calendar.__preloading = true;
var currentPromise = Promise.all(requests)
.then(function(timeSlotsArray) {
var allTimeSlots = ActiveResource.Collection
.build(timeSlotsArray)
.map(function(ts) { return ts.toArray() })
.flatten();
let startDate = moment(lowerRange).startOf('month');
let endDate = moment(lowerRange).endOf('month');
var response = ActiveResource.CollectionResponse.build();
let day = startDate;
while(day.isSameOrBefore(endDate)) {
response.push({
day,
timeSlots: allTimeSlots.select((timeSlot) => { return timeSlot.startsAt.isSame(day, 'day'); })
});
day = day.clone().add(1, 'days');
}
response.hasNextPage = function() { return true; };
let commonPaginationOptions = {
calendar,
monthlyTimeSlotDaysBatchSize,
monthlyTimeSlotPreloadSize,
relation,
status,
timeZone
};
response.nextPage = function(preloadCount) {
if(!this.nextPromise) {
this.nextPromise = Occasion.__constructCalendar(
moment(upperRange).add(1, 'days').startOf('month'),
{
...commonPaginationOptions,
preload: preloadCount,
prevPagePromise: currentPromise,
},
);
}
if(_.isUndefined(preloadCount)) {
calendar.__currentPage += 1;
if(!calendar.__preloading &&
calendar.__preloadedPages <= calendar.__currentPage + monthlyTimeSlotPreloadSize / 2) {
calendar.__lastPreloadedPage.nextPage(monthlyTimeSlotPreloadSize);
}
}
return this.nextPromise;
};
if(status !== 'bookable' || (month && !month.isSame(today, 'month'))) {
response.hasPrevPage = function() { return true; };
response.prevPage = function() {
this.prevPromise = this.prevPromise || prevPagePromise ||
Occasion.__constructCalendar(
moment(lowerRange).subtract(1, 'months'),
{
...commonPaginationOptions,
preload: 0,
}
);
calendar.__currentPage -= 1;
return this.prevPromise;
};
}
if(monthlyTimeSlotPreloadSize > 0) {
if(_.isUndefined(preload)) {
response.nextPage(monthlyTimeSlotPreloadSize - 1);
} else if(preload > 0) {
response.nextPage(--preload);
} else {
calendar.__preloading = false;
}
}
calendar.__preloadedPages += 1;
calendar.__lastPreloadedPage = response;
return response;
});
return currentPromise;
};
Occasion.Modules.push(function(library) {
library.Answer = class Answer extends library.Base {
valid() {
switch(this.question().formControl) {
case 'checkbox':
case 'waiver':
return !(this.question().required || this.question().formControl == 'waiver') ||
(this.value == 'YES' || (this.value != 'NO' && this.value));
default:
return !this.question().required ||
((this.question().optionable && this.option()) ||
(!this.question().optionable && this.value && this.value != ''));
}
}
};
library.Answer.className = 'Answer';
library.Answer.queryName = 'answers';
library.Answer.attributes('value');
library.Answer.belongsTo('question');
library.Answer.belongsTo('option');
library.Answer.belongsTo('order', { inverseOf: 'answers' });
});
Occasion.Modules.push(function(library) {
library.Attendee = class Attendee extends library.Base {
complete() {
return !this.order().product().attendeeQuestions.detect((question) => {
return !this[question] || this[question].length == 0;
});
}
};
library.Attendee.className = 'Attendee';
library.Attendee.queryName = 'attendees';
library.Attendee.attributes(
'address',
'age',
'city',
'country',
'email',
'firstName',
'gender',
'lastName',
'phone',
'state',
'zip'
);
library.Attendee.belongsTo('order', { inverseOf: 'attendees' });
});
Occasion.Modules.push(function(library) {
library.Coupon = class Coupon extends library.Base {};
library.Coupon.className = 'Coupon';
library.Coupon.queryName = 'coupons';
library.Coupon.belongsTo('merchant');
library.Coupon.hasMany('orders');
});
Occasion.Modules.push(function(library) {
library.Currency = class Currency extends library.Base {};
library.Currency.className = 'Currency';
library.Currency.queryName = 'currencies';
library.Currency.hasMany('merchants');
library.Currency.hasMany('orders');
});
Occasion.Modules.push(function(library) {
library.Customer = class Customer extends library.Base {
ahoyEmailChanged() {
/* TODO: Align customer data with Ahoy using +this+ */
}
complete() {
return this.email && this.firstName && this.lastName &&
this.email.length > 0 && this.firstName.length > 0 && this.lastName.length > 0;
}
};
library.Customer.className = 'Customer';
library.Customer.queryName = 'customers';
library.Customer.attributes('email', 'firstName', 'lastName', 'zip');
library.Customer.hasMany('orders', { inverseOf: 'customer' });
library.Customer.afterBuild(function() {
var lastEmail = null;
var watchEmail = _.bind(function() {
if(lastEmail != this.email) {
_.bind(this.ahoyEmailChanged, this)();
lastEmail = this.email;
}
setTimeout(watchEmail, 500);
}, this);
setTimeout(watchEmail, 500);
});
});
Occasion.Modules.push(function(library) {
library.Label = class Label extends library.Base {};
library.Label.className = 'Label';
library.Label.queryName = 'labels';
library.Label.belongsTo('product');
});
Occasion.Modules.push(function(library) {
library.Merchant = class Merchant extends library.Base {};
library.Merchant.className = 'Merchant';
library.Merchant.queryName = 'merchants';
library.Merchant.belongsTo('currency');
library.Merchant.hasMany('products');
library.Merchant.hasMany('venues');
});
Occasion.Modules.push(function(library) {
library.Option = class Option extends library.Base {};
library.Option.className = 'Option';
library.Option.queryName = 'options';
library.Option.belongsTo('answer');
library.Option.belongsTo('question');
});
Occasion.Modules.push(function(library) {
library.Order = class Order extends library.Base {
static construct(attributes) {
var order = this.includes("currency").build(attributes);
order.sessionIdentifier =
order.sessionIdentifier ||
Math.random()
.toString(36)
.substring(7) +
"-" +
Date.now();
if (order.customer() == null) {
order.buildCustomer({
email: null,
firstName: null,
lastName: null,
zip: null
});
}
var promises = [
new Promise(function(resolve) {
resolve(order);
})
];
if (order.product() != null) {
promises.push(
order
.product()
.questions()
.includes("options")
.perPage(500)
.load()
);
if (!order.product().requiresTimeSlotSelection) {
promises.push(
order
.product()
.timeSlots()
.includes({ product: "merchant" })
.where({ status: "bookable" })
.perPage(500)
.all()
);
}
}
var _this = this;
return Promise.all(promises).then(function(args) {
order = args[0];
var questions = args[1];
var timeSlots = args[2];
if (questions != undefined) questions.inject(order, _this.__constructAnswer);
if (timeSlots != undefined) order.timeSlots().assign(timeSlots, false);
return order;
});
}
// Creates a transaction with a payment method and an amount
//
// @param [PaymentMethod] paymentMethod the payment method to charge
// @param [Number] amount the amount to charge to the payment method
// @return [Transaction] the built transaction representing the charge
charge(paymentMethod, amount) {
return this.transactions().build({
amount: amount,
paymentMethod: paymentMethod
});
}
// Edits a transaction with a given payment method to have a new amount
//
// @param [PaymentMethod] paymentMethod the payment method to search transactions for
// @param [Number] amount the new amount to charge to the payment method
// @return [Transaction] the edited transaction representing the charge
editCharge(paymentMethod, amount) {
var transaction = this.transactions()
.target()
.detect(function(t) {
return t.paymentMethod() == paymentMethod;
});
if (transaction) {
transaction.amount = amount;
}
}
// Removes a transaction for a given payment method
//
// @param [PaymentMethod] paymentMethod the payment method to remove the transaction for
removeCharge(paymentMethod) {
var transaction = this.transactions()
.target()
.detect(function(t) {
return t.paymentMethod() == paymentMethod;
});
if (transaction) {
this.transactions()
.target()
.delete(transaction);
}
}
// @private
// Called by Order.construct, which injects order
// @note Must return order
//
// @param [Occsn.Order] order the order that wants an answer to the question
// @param [Occsn.Question] question the question to construct an answer for
static __constructAnswer(order, question) {
if (question.category != "static") {
let answer = order.answers().build({
question: question
});
switch (question.formControl) {
case "drop_down":
case "option_list":
answer.assignOption(
question
.options()
.target()
.detect(o => {
return o.default;
})
);
break;
case "spin_button":
answer.value = question.min;
break;
}
}
return order;
}
};
library.Order.className = "Order";
library.Order.queryName = "orders";
library.Order.attributes("sessionIdentifier", "status");
library.Order.attributes(
"couponAmount",
"dropInsDiscount",
"giftCardAmount",
"outstandingBalance",
"price",
"priceDueOnInitialOrder",
"quantity",
"subtotal",
"tax",
"taxPercentage",
"total",
"totalDiscount",
{ readOnly: true }
);
library.Order.belongsTo("coupon");
library.Order.belongsTo("currency");
library.Order.belongsTo("customer", { autosave: true, inverseOf: "orders" });
library.Order.belongsTo("merchant");
library.Order.belongsTo("product");
library.Order.hasMany("answers", { autosave: true, inverseOf: "order" });
library.Order.hasMany("attendees", { autosave: true, inverseOf: "order" });
library.Order.hasMany("timeSlots");
library.Order.hasMany("transactions", { autosave: true, inverseOf: "order" });
library.Order.afterRequest(function() {
if (this.product() && !this.product().attendeeQuestions.empty()) {
var diff = this.quantity - this.attendees().size();
if (diff != 0) {
for (var i = 0; i < Math.abs(diff); i++) {
if (diff > 0) {
this.attendees().build();
} else {
this.attendees()
.target()
.pop();
}
}
}
}
ActiveResource.Collection.build([
"couponAmount",
"dropInsDiscount",
"giftCardAmount",
"outstandingBalance",
"price",
"priceDueOnInitialOrder",
"quantity",
"subtotal",
"tax",
"taxPercentage",
"total",
"totalDiscount"
])
.select(attr => this[attr])
.each(attr => {
this[attr] = new Decimal(this[attr]);
});
if (this.outstandingBalance && !this.outstandingBalance.isZero()) {
var giftCardTransactions = this.transactions()
.target()
.select(t => t.paymentMethod() && t.paymentMethod().isA(library.GiftCard));
var remainingBalanceTransaction = this.transactions()
.target()
.detect(t => !(t.paymentMethod() && t.paymentMethod().isA(library.GiftCard)));
if (this.outstandingBalance.isPositive()) {
if (!giftCardTransactions.empty()) {
giftCardTransactions.each(t => {
if (this.outstandingBalance.isZero()) return;
let amount = new Decimal(t.amount);
let giftCardValue = new Decimal(t.paymentMethod().value);
let remainingGiftCardBalance = giftCardValue.minus(amount);
if (remainingGiftCardBalance.isZero()) return;
if (remainingGiftCardBalance.greaterThanOrEqualTo(this.outstandingBalance)) {
amount = amount.plus(this.outstandingBalance);
this.outstandingBalance = new Decimal(0);
} else {
amount = remainingGiftCardBalance;
this.outstandingBalance = this.outstandingBalance.minus(remainingGiftCardBalance);
}
t.amount = amount.toString();
this.transactions()
.target()
.delete(t);
t.__createClone({ cloner: this });
});
}
} else {
if (!giftCardTransactions.empty()) {
ActiveResource.Collection.build(giftCardTransactions.toArray().reverse()).each(t => {
if (this.outstandingBalance.isZero()) return;
let amount = new Decimal(t.amount);
if (amount.greaterThan(this.outstandingBalance.abs())) {
amount = amount.plus(this.outstandingBalance);
this.outstandingBalance = new Decimal(0);
} else {
this.outstandingBalance = this.outstandingBalance.plus(amount);
this.removeCharge(t.paymentMethod());
return;
}
t.amount = amount.toString();
this.transactions()
.target()
.delete(t);
t.__createClone({ cloner: this });
});
}
}
if (!giftCardTransactions.empty()) {
this.giftCardAmount = this.transactions()
.target()
.select(t => t.paymentMethod() && t.paymentMethod().isA(library.GiftCard))
.inject(new Decimal(0), (total, transaction) => total.plus(transaction.amount));
}
if (remainingBalanceTransaction) {
remainingBalanceTransaction.amount = this.outstandingBalance.toString();
this.transactions()
.target()
.delete(remainingBalanceTransaction);
remainingBalanceTransaction.__createClone({ cloner: this });
}
}
});
});
Occasion.Modules.push(function(library) {
library.PaymentMethod = class PaymentMethod extends library.Base {};
library.PaymentMethod.className = 'PaymentMethod';
library.PaymentMethod.queryName = 'payment_methods';
library.PaymentMethod.hasMany('transactions', { as: 'paymentMethod' });
});
Occasion.Modules.push(function(library) {
library.Product = class Product extends library.Base {
constructCalendar(month, options = {}) {
return Occasion.__constructCalendar(
month,
{
monthlyTimeSlotDaysBatchSize: this.monthlyTimeSlotDaysBatchSize,
monthlyTimeSlotPreloadSize: this.monthlyTimeSlotPreloadSize,
relation: this.timeSlots(),
timeZone: this.merchant().timeZone,
...options
}
);
}
}
library.Product.className = 'Product';
library.Product.queryName = 'products';
library.Product.belongsTo('merchant');
library.Product.belongsTo('venue');
library.Product.hasMany('labels');
library.Product.hasMany('orders');
library.Product.hasMany('questions');
library.Product.hasMany('redeemables');
library.Product.hasMany('timeSlots');
library.Product.afterRequest(function() {
this.attendeeQuestions =
ActiveResource.Collection.build(this.attendeeQuestions)
.map((q) => { return s.camelize(q, true); });
if(this.firstTimeSlotStartsAt) {
if(this.merchant()) {
this.firstTimeSlotStartsAt = moment.tz(this.firstTimeSlotStartsAt, this.merchant().timeZone);
} else {
throw 'Product has timeslots - but merchant.timeZone is not available; include merchant in response.';
}
}
});
});
Occasion.Modules.push(function(library) {
library.Question = class Question extends library.Base {};
library.Question.className = 'Question';
library.Question.queryName = 'questions';
library.Question.belongsTo('product');
library.Question.hasMany('answers');
library.Question.hasMany('options');
});
Occasion.Modules.push(function(library) {
// TODO: Remove ability to directly query redeemables
library.Redeemable = class Redeemable extends library.Base {};
library.Redeemable.className = 'Redeemable';
library.Redeemable.queryName = 'redeemables';
library.Redeemable.belongsTo('product');
});
Occasion.Modules.push(function(library) {
library.State = class State extends library.Base {};
library.State.className = 'State';
library.State.queryName = 'states';
});
Occasion.Modules.push(function(library) {
library.TimeSlot = class TimeSlot extends library.Base {
static constructCalendar = function() {
let month, options;
if(moment.isMoment(arguments[0])) {
month = arguments[0];
options = arguments[1] || {};
} else {
options = arguments[0] || {};
}
return Occasion.__constructCalendar(
month,
{
monthlyTimeSlotDaysBatchSize: 7,
monthlyTimeSlotPreloadSize: 4,
relation: this,
...options
}
);
};
toString(format) {
var output;
if(this.product().showTimeSlotDuration) {
var durationTimeSlot = this.startsAt.clone().add(this.duration);
var durationFormat;
if(durationTimeSlot.isSame(this.startsAt, 'day')) {
durationFormat = 'LT';
} else {
durationFormat = 'LLLL';
}
output = this.startsAt.format(format);
output += ' - ';
output += durationTimeSlot.format(durationFormat);
} else {
output = this.startsAt.format(format);
}
return output;
}
};
library.TimeSlot.className = 'TimeSlot';
library.TimeSlot.queryName = 'time_slots';
library.TimeSlot.belongsTo('order');
library.TimeSlot.belongsTo('product');
library.TimeSlot.belongsTo('venue');
library.TimeSlot.afterRequest(function() {
if(this.product().merchant()) {
this.startsAt = moment.tz(this.startsAt, this.product().merchant().timeZone);
} else {
throw 'Must use includes({ product: \'merchant\' }) in timeSlot request';
}
this.duration = moment.duration(this.duration, 'minutes');
});
});
Occasion.Modules.push(function(library) {
library.Transaction = class Transaction extends library.Base {};
library.Transaction.className = 'Transaction';
library.Transaction.queryName = 'transactions';
library.Transaction.attributes('amount');
library.Transaction.belongsTo('order', { inverseOf: 'transactions' });
library.Transaction.belongsTo('paymentMethod', { polymorphic: true });
});
Occasion.Modules.push(function(library) {
library.Venue = class Venue extends library.Base {};
library.Venue.className = 'Venue';
library.Venue.queryName = 'venues';
library.Venue.belongsTo('merchant');
library.Venue.belongsTo('state');
library.Venue.hasMany('products');
});
Occasion.Modules.push(function(library) {
library.CreditCard = class CreditCard extends library.PaymentMethod {};
library.CreditCard.className = 'CreditCard';
library.CreditCard.queryName = 'credit_cards';
library.CreditCard.hasMany('transactions', { as: 'paymentMethod' });
});
Occasion.Modules.push(function(library) {
library.GiftCard = class GiftCard extends library.PaymentMethod {};
library.GiftCard.className = 'GiftCard';
library.GiftCard.queryName = 'gift_cards';
library.GiftCard.belongsTo('customer');
library.GiftCard.hasMany('transactions', { as: 'paymentMethod' });
});