ti2-bokun 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -0
- package/index.js +1026 -0
- package/package.json +58 -0
- package/resolvers/availability.js +366 -0
- package/resolvers/booking.js +356 -0
- package/resolvers/pickup-point.js +158 -0
- package/resolvers/product.js +155 -0
- package/resolvers/rate.js +33 -0
- package/utils/wildcardMatch.js +16 -0
package/index.js
ADDED
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
const R = require('ramda');
|
|
2
|
+
const Promise = require('bluebird');
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const moment = require('moment');
|
|
5
|
+
const jwt = require('jsonwebtoken');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const { translateProduct } = require('./resolvers/product');
|
|
8
|
+
const { translateAvailability } = require('./resolvers/availability');
|
|
9
|
+
const { translateBooking } = require('./resolvers/booking');
|
|
10
|
+
|
|
11
|
+
const CONCURRENCY = 3;
|
|
12
|
+
|
|
13
|
+
const isNilOrEmpty = R.either(R.isNil, R.isEmpty);
|
|
14
|
+
|
|
15
|
+
const toStringSafe = value => (value === undefined || value === null ? null : value.toString());
|
|
16
|
+
|
|
17
|
+
/** Derive sales UI base URL from API endpoint and tenant. e.g. api.bokun.io + tourconnect-llc -> https://tourconnect-llc.bokun.io/ */
|
|
18
|
+
const getSalesBaseUrlFromEndpoint = (endpoint, tenantSlug) => {
|
|
19
|
+
if (!tenantSlug || !endpoint) return null;
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(endpoint);
|
|
22
|
+
const { hostname } = url;
|
|
23
|
+
const salesHost = hostname.replace(/^api\./, `${tenantSlug}.`);
|
|
24
|
+
return `https://${salesHost}/`;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const normaliseOptionIds = (productIds, optionIds = []) => {
|
|
31
|
+
if (!Array.isArray(productIds) || productIds.length === 0) return [];
|
|
32
|
+
|
|
33
|
+
if (!Array.isArray(optionIds) || optionIds.length === 0) {
|
|
34
|
+
return productIds.map(() => null);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (optionIds.length === 1 && productIds.length > 1) {
|
|
38
|
+
return productIds.map(() => optionIds[0]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (optionIds.length !== productIds.length) {
|
|
42
|
+
throw new Error('mismatched productIds/options length');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return optionIds;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const normaliseUnits = (productIds, units = []) => {
|
|
49
|
+
if (!Array.isArray(productIds) || productIds.length === 0) return [];
|
|
50
|
+
|
|
51
|
+
if (!Array.isArray(units) || units.length === 0) {
|
|
52
|
+
return productIds.map(() => []);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Units might be provided as a single array of configs that should apply to every product
|
|
56
|
+
if (!Array.isArray(units[0])) {
|
|
57
|
+
if (units.length === productIds.length && units.every(u => typeof u === 'object' && u !== null)) {
|
|
58
|
+
return units.map(unitConfig => (Array.isArray(unitConfig) ? unitConfig : [unitConfig]));
|
|
59
|
+
}
|
|
60
|
+
return productIds.map(() => units);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (units.length !== productIds.length) {
|
|
64
|
+
throw new Error('mismatched options/units length');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return units;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const withOriginalRetailNet = (obj, amount) => {
|
|
71
|
+
const value = amount !== undefined && amount !== null ? amount : (obj.price !== undefined && obj.price !== null ? obj.price : 0);
|
|
72
|
+
return {
|
|
73
|
+
...obj,
|
|
74
|
+
original: obj.original !== undefined && obj.original !== null ? obj.original : value,
|
|
75
|
+
retail: obj.retail !== undefined && obj.retail !== null ? obj.retail : value,
|
|
76
|
+
net: obj.net !== undefined && obj.net !== null ? obj.net : value,
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const extractMoney = (moneyValue, fallbackCurrency) => {
|
|
81
|
+
if (moneyValue === undefined || moneyValue === null) {
|
|
82
|
+
return withOriginalRetailNet({
|
|
83
|
+
price: 0,
|
|
84
|
+
currency: fallbackCurrency || 'USD',
|
|
85
|
+
currencyPrecision: 2,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof moneyValue === 'number') {
|
|
90
|
+
return withOriginalRetailNet({
|
|
91
|
+
price: moneyValue,
|
|
92
|
+
currency: fallbackCurrency || 'USD',
|
|
93
|
+
currencyPrecision: 2,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const rawAmount = typeof moneyValue.amount === 'number'
|
|
98
|
+
? moneyValue.amount
|
|
99
|
+
: parseFloat(moneyValue.amount || 0);
|
|
100
|
+
const amount = Number.isFinite(rawAmount) ? rawAmount : 0;
|
|
101
|
+
let precision;
|
|
102
|
+
if (typeof moneyValue.currencyPrecision === 'number') {
|
|
103
|
+
precision = moneyValue.currencyPrecision;
|
|
104
|
+
} else if (typeof moneyValue.scale === 'number') {
|
|
105
|
+
precision = moneyValue.scale;
|
|
106
|
+
} else {
|
|
107
|
+
precision = null;
|
|
108
|
+
}
|
|
109
|
+
const currency = moneyValue.currency || fallbackCurrency || 'USD';
|
|
110
|
+
|
|
111
|
+
if (precision === null) {
|
|
112
|
+
return withOriginalRetailNet({
|
|
113
|
+
price: amount,
|
|
114
|
+
currency,
|
|
115
|
+
currencyPrecision: 2,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const scaledPrice = amount / (10 ** precision);
|
|
120
|
+
return withOriginalRetailNet({
|
|
121
|
+
price: Number.isFinite(scaledPrice) ? scaledPrice : 0,
|
|
122
|
+
currency,
|
|
123
|
+
currencyPrecision: precision,
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const computeAvailabilityCount = availability => {
|
|
128
|
+
if (availability.availabilityCount != null) return availability.availabilityCount;
|
|
129
|
+
if (availability.capacity != null) return availability.capacity;
|
|
130
|
+
if (availability.remainingCapacity != null) return availability.remainingCapacity;
|
|
131
|
+
if (availability.vacancy != null) return availability.vacancy;
|
|
132
|
+
if (availability.vacancies != null) return availability.vacancies;
|
|
133
|
+
return 0;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const normalizeAvailabilityPricing = availability => {
|
|
137
|
+
if (!availability) return availability;
|
|
138
|
+
const rawPricing = availability.pricing;
|
|
139
|
+
const rawUnitPricing = availability.unitPricing || [];
|
|
140
|
+
let pricingArray = null;
|
|
141
|
+
if (Array.isArray(rawPricing)) {
|
|
142
|
+
pricingArray = rawPricing;
|
|
143
|
+
} else if (rawPricing != null && typeof rawPricing === 'object') {
|
|
144
|
+
const price = rawPricing.retail != null ? rawPricing.retail : (rawPricing.original != null ? rawPricing.original : (rawPricing.net != null ? rawPricing.net : rawPricing.price));
|
|
145
|
+
pricingArray = [{ ...rawPricing, price }];
|
|
146
|
+
}
|
|
147
|
+
const unitPricingArray = rawUnitPricing.map(unit => {
|
|
148
|
+
if (unit.pricing != null && (Array.isArray(unit.pricing) ? unit.pricing.length > 0 : true)) {
|
|
149
|
+
return unit;
|
|
150
|
+
}
|
|
151
|
+
const p = {
|
|
152
|
+
original: unit.original,
|
|
153
|
+
retail: unit.retail,
|
|
154
|
+
net: unit.net,
|
|
155
|
+
currency: unit.currency || 'USD',
|
|
156
|
+
currencyPrecision: unit.currencyPrecision != null ? unit.currencyPrecision : 2,
|
|
157
|
+
price: unit.retail != null ? unit.retail : (unit.original != null ? unit.original : (unit.net != null ? unit.net : unit.price)),
|
|
158
|
+
};
|
|
159
|
+
return { ...unit, pricing: [p] };
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
...availability,
|
|
163
|
+
pricing: pricingArray || availability.pricing,
|
|
164
|
+
unitPricing: unitPricingArray,
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const mapOctoPricingToRate = availability => {
|
|
169
|
+
const normalized = normalizeAvailabilityPricing(availability);
|
|
170
|
+
const price = normalized && normalized.pricing && normalized.pricing[0] ? normalized.pricing[0] : null;
|
|
171
|
+
if (!price) return null;
|
|
172
|
+
const unitPricingList = (normalized && normalized.unitPricing) ? normalized.unitPricing : [];
|
|
173
|
+
const pricePerCategoryUnit = unitPricingList.map(unitPricing => ({
|
|
174
|
+
id: unitPricing.unitId,
|
|
175
|
+
amount: {
|
|
176
|
+
currency: price.currency,
|
|
177
|
+
amount: (unitPricing.pricing && unitPricing.pricing[0] ? unitPricing.pricing[0].retail : null) || (unitPricing.pricing && unitPricing.pricing[0] ? unitPricing.pricing[0].price : null) || price.retail,
|
|
178
|
+
currencyPrecision: price.currencyPrecision,
|
|
179
|
+
},
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
rateId: (normalized && normalized.optionId) || (normalized && normalized.rateId) || (normalized && normalized.rateReference) || null,
|
|
184
|
+
pricePerCategoryUnit,
|
|
185
|
+
pricePerBooking: {
|
|
186
|
+
amount: {
|
|
187
|
+
currency: price.currency,
|
|
188
|
+
amount: price.retail,
|
|
189
|
+
currencyPrecision: price.currencyPrecision,
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const buildUnitPricing = ({ units, ratePricing, fallbackCurrency }) => {
|
|
196
|
+
if (!Array.isArray(units) || units.length === 0) return [];
|
|
197
|
+
|
|
198
|
+
const pricePerUnit = (ratePricing && ratePricing.pricePerCategoryUnit) || [];
|
|
199
|
+
const pricePerBooking = ratePricing && ratePricing.pricePerBooking;
|
|
200
|
+
|
|
201
|
+
return units.map(unit => {
|
|
202
|
+
const { unitId } = unit;
|
|
203
|
+
const matchingCategory = pricePerUnit.find(entry => toStringSafe(entry.id) === toStringSafe(unitId));
|
|
204
|
+
const pricingAmount = matchingCategory
|
|
205
|
+
? extractMoney(matchingCategory.amount, fallbackCurrency)
|
|
206
|
+
: extractMoney((pricePerBooking && pricePerBooking.amount) || pricePerBooking, fallbackCurrency);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
unitId,
|
|
210
|
+
pricing: [pricingAmount],
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const parseAvailabilityDate = availability => {
|
|
216
|
+
if (!availability) return null;
|
|
217
|
+
|
|
218
|
+
if (availability.localDateTimeStart) {
|
|
219
|
+
const localDateTime = moment(availability.localDateTimeStart);
|
|
220
|
+
if (localDateTime.isValid()) return localDateTime;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (availability.localDate) {
|
|
224
|
+
const localMoment = moment(availability.localDate);
|
|
225
|
+
if (localMoment.isValid()) return localMoment;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (availability.date) {
|
|
229
|
+
const dateValue = availability.date;
|
|
230
|
+
const parsed = typeof dateValue === 'number'
|
|
231
|
+
? moment(dateValue)
|
|
232
|
+
: moment(dateValue, ['YYYY-MM-DD', moment.ISO_8601], true);
|
|
233
|
+
if (parsed.isValid()) return parsed;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (availability.localizedDate) {
|
|
237
|
+
const parsed = moment(availability.localizedDate, moment.ISO_8601);
|
|
238
|
+
if (parsed.isValid()) return parsed;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return null;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const buildAvailabilityRoot = ({
|
|
245
|
+
availability,
|
|
246
|
+
productId,
|
|
247
|
+
rateId,
|
|
248
|
+
units,
|
|
249
|
+
currency,
|
|
250
|
+
}) => {
|
|
251
|
+
const availabilityDate = parseAvailabilityDate(availability);
|
|
252
|
+
const localDate = availabilityDate ? availabilityDate.format('YYYY-MM-DD') : null;
|
|
253
|
+
const startMoment = availability.localDateTimeStart ? moment(availability.localDateTimeStart) : null;
|
|
254
|
+
const endMoment = availability.localDateTimeEnd ? moment(availability.localDateTimeEnd) : null;
|
|
255
|
+
const time = availability.startTime
|
|
256
|
+
|| availability.time
|
|
257
|
+
|| availability.startTimeLocal
|
|
258
|
+
|| (startMoment ? startMoment.format('HH:mm') : '00:00');
|
|
259
|
+
let computedDuration;
|
|
260
|
+
if (startMoment && endMoment) {
|
|
261
|
+
computedDuration = Math.max(endMoment.diff(startMoment, 'minutes'), 0);
|
|
262
|
+
} else if (availability.allDay) {
|
|
263
|
+
computedDuration = 24 * 60;
|
|
264
|
+
} else {
|
|
265
|
+
computedDuration = 120;
|
|
266
|
+
}
|
|
267
|
+
const durationMinutes = availability.durationMinutes != null ? availability.durationMinutes : (availability.duration != null ? availability.duration : computedDuration);
|
|
268
|
+
const localStart = startMoment || (localDate ? moment(`${localDate}T${time}`) : null);
|
|
269
|
+
const localEnd = endMoment || (localStart ? moment(localStart).add(durationMinutes, 'minutes') : null);
|
|
270
|
+
|
|
271
|
+
const availabilityCount = computeAvailabilityCount(availability);
|
|
272
|
+
|
|
273
|
+
const isSoldOutExplicit = availability.soldOut === true
|
|
274
|
+
|| availability.unavailable === true
|
|
275
|
+
|| availability.status === 'SOLD_OUT';
|
|
276
|
+
const isAvailable = !isSoldOutExplicit && availabilityCount > 0;
|
|
277
|
+
const status = isAvailable ? 'AVAILABLE' : 'SOLD_OUT';
|
|
278
|
+
|
|
279
|
+
const fallbackCurrency = currency
|
|
280
|
+
|| availability.currency
|
|
281
|
+
|| (availability.pricing && availability.pricing[0] ? availability.pricing[0].currency : null)
|
|
282
|
+
|| (availability.pricesByRate && availability.pricesByRate[0] && availability.pricesByRate[0].pricePerCategoryUnit && availability.pricesByRate[0].pricePerCategoryUnit[0] && availability.pricesByRate[0].pricePerCategoryUnit[0].amount ? availability.pricesByRate[0].pricePerCategoryUnit[0].amount.currency : null)
|
|
283
|
+
|| 'USD';
|
|
284
|
+
|
|
285
|
+
const ratePricingSource = availability.ratePricing || mapOctoPricingToRate(availability);
|
|
286
|
+
|
|
287
|
+
let unitPricing = buildUnitPricing({
|
|
288
|
+
units,
|
|
289
|
+
ratePricing: ratePricingSource,
|
|
290
|
+
fallbackCurrency,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
let resolvedPricing = (unitPricing[0] && unitPricing[0].pricing) || [];
|
|
294
|
+
|
|
295
|
+
const hasApiPricing = availability.pricing != null || (availability.unitPricing || []).length > 0;
|
|
296
|
+
if (hasApiPricing) {
|
|
297
|
+
const normalized = normalizeAvailabilityPricing(availability);
|
|
298
|
+
const fillPricingItem = p => ({
|
|
299
|
+
...p,
|
|
300
|
+
price: p.price != null ? p.price : (p.retail != null ? p.retail : (p.original != null ? p.original : (p.net != null ? p.net : 0))),
|
|
301
|
+
original: p.original != null ? p.original : (p.retail != null ? p.retail : (p.price != null ? p.price : 0)),
|
|
302
|
+
retail: p.retail != null ? p.retail : (p.original != null ? p.original : (p.price != null ? p.price : 0)),
|
|
303
|
+
net: p.net != null ? p.net : (p.retail != null ? p.retail : (p.original != null ? p.original : (p.price != null ? p.price : 0))),
|
|
304
|
+
});
|
|
305
|
+
if (normalized.pricing && (Array.isArray(normalized.pricing) ? normalized.pricing.length > 0 : true)) {
|
|
306
|
+
const pricingArr = Array.isArray(normalized.pricing) ? normalized.pricing : [normalized.pricing];
|
|
307
|
+
resolvedPricing = pricingArr.map(fillPricingItem);
|
|
308
|
+
}
|
|
309
|
+
if ((normalized.unitPricing || []).length > 0) {
|
|
310
|
+
unitPricing = normalized.unitPricing.map(unit => {
|
|
311
|
+
const hasNested = unit.pricing != null && (Array.isArray(unit.pricing) ? unit.pricing.length > 0 : true);
|
|
312
|
+
if (hasNested) {
|
|
313
|
+
const arr = Array.isArray(unit.pricing) ? unit.pricing : [unit.pricing];
|
|
314
|
+
return { ...unit, pricing: arr.map(fillPricingItem) };
|
|
315
|
+
}
|
|
316
|
+
const price = unit.retail != null ? unit.retail : (unit.original != null ? unit.original : (unit.net != null ? unit.net : (unit.price != null ? unit.price : 0)));
|
|
317
|
+
const p = fillPricingItem({
|
|
318
|
+
original: unit.original != null ? unit.original : (unit.retail != null ? unit.retail : price),
|
|
319
|
+
retail: unit.retail != null ? unit.retail : (unit.original != null ? unit.original : price),
|
|
320
|
+
net: unit.net != null ? unit.net : (unit.retail != null ? unit.retail : (unit.original != null ? unit.original : price)),
|
|
321
|
+
currency: unit.currency || fallbackCurrency,
|
|
322
|
+
currencyPrecision: unit.currencyPrecision != null ? unit.currencyPrecision : 2,
|
|
323
|
+
price,
|
|
324
|
+
});
|
|
325
|
+
return { ...unit, pricing: [p] };
|
|
326
|
+
});
|
|
327
|
+
if (resolvedPricing.length === 0 && unitPricing[0] && unitPricing[0].pricing && unitPricing[0].pricing.length) {
|
|
328
|
+
resolvedPricing = unitPricing[0].pricing;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const availabilityId = availability.id || availability.availabilityId || availability.startTimeId;
|
|
334
|
+
const startTimeId = availability.startTimeId || availabilityId;
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
id: availabilityId ? availabilityId.toString() : `${productId}-${localDate || ''}-${time}`,
|
|
338
|
+
availabilityId: availabilityId ? availabilityId.toString() : null,
|
|
339
|
+
startTimeId: startTimeId ? startTimeId.toString() : null,
|
|
340
|
+
productId: productId.toString(),
|
|
341
|
+
date: localDate,
|
|
342
|
+
time,
|
|
343
|
+
localDateTimeStart: localStart ? localStart.format() : null,
|
|
344
|
+
localDateTimeEnd: localEnd ? localEnd.format() : null,
|
|
345
|
+
utcDateTimeStart: localStart ? moment.utc(localStart).format() : null,
|
|
346
|
+
utcDateTimeEnd: localEnd ? moment.utc(localEnd).format() : null,
|
|
347
|
+
allDay: Boolean(availability.allDay),
|
|
348
|
+
available: isAvailable,
|
|
349
|
+
status,
|
|
350
|
+
availabilityCount,
|
|
351
|
+
vacancies: availabilityCount,
|
|
352
|
+
capacity: availabilityCount,
|
|
353
|
+
maxUnits: availabilityCount,
|
|
354
|
+
unitPricing,
|
|
355
|
+
pricing: resolvedPricing,
|
|
356
|
+
rateId,
|
|
357
|
+
currency: fallbackCurrency,
|
|
358
|
+
offers: availability.offers,
|
|
359
|
+
pickupAvailable: availability.pickupAvailable || availability.pickupAllotment || false,
|
|
360
|
+
pickupRequired: availability.pickupRequired || false,
|
|
361
|
+
pricingFrom: resolvedPricing,
|
|
362
|
+
unitPricingFrom: unitPricing,
|
|
363
|
+
ratePricing: availability.ratePricing,
|
|
364
|
+
};
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const buildOctoUnitsPayload = units => (
|
|
368
|
+
(units || [])
|
|
369
|
+
.map(unit => {
|
|
370
|
+
if (!unit) return null;
|
|
371
|
+
const id = unit.unitId || unit.id;
|
|
372
|
+
if (!id) return null;
|
|
373
|
+
return {
|
|
374
|
+
id: id.toString(),
|
|
375
|
+
quantity: Number.isFinite(unit.quantity) ? unit.quantity : 1,
|
|
376
|
+
};
|
|
377
|
+
})
|
|
378
|
+
.filter(Boolean)
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const getHeaders = ({
|
|
382
|
+
apiKey,
|
|
383
|
+
}) => ({
|
|
384
|
+
Authorization: `Bearer ${apiKey}`,
|
|
385
|
+
'Content-Type': 'application/json',
|
|
386
|
+
'Octo-Capabilities': 'octo/pricing,octo/pickups,octo/cart,octo/offers,octo/questions',
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const fetchAvailabilityForProduct = async ({
|
|
390
|
+
axios,
|
|
391
|
+
apiKey,
|
|
392
|
+
baseUrl,
|
|
393
|
+
productId,
|
|
394
|
+
startDate,
|
|
395
|
+
endDate,
|
|
396
|
+
currency,
|
|
397
|
+
optionId,
|
|
398
|
+
units,
|
|
399
|
+
availTypeDefs,
|
|
400
|
+
availQuery,
|
|
401
|
+
jwtKey,
|
|
402
|
+
}) => {
|
|
403
|
+
const headers = getHeaders({
|
|
404
|
+
apiKey,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const payload = {
|
|
408
|
+
productId,
|
|
409
|
+
optionId,
|
|
410
|
+
localDateStart: startDate,
|
|
411
|
+
localDateEnd: endDate,
|
|
412
|
+
units: buildOctoUnitsPayload(units),
|
|
413
|
+
};
|
|
414
|
+
if (currency) payload.currency = currency;
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
const response = await axios({
|
|
418
|
+
method: 'post',
|
|
419
|
+
url: `${baseUrl}/availability`,
|
|
420
|
+
data: payload,
|
|
421
|
+
headers,
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const availabilities = R.pathOr([], ['data'], response);
|
|
425
|
+
|
|
426
|
+
const translated = await Promise.map(availabilities, async availability => {
|
|
427
|
+
const rootValue = buildAvailabilityRoot({
|
|
428
|
+
availability,
|
|
429
|
+
productId,
|
|
430
|
+
rateId: optionId,
|
|
431
|
+
units,
|
|
432
|
+
currency: (availability && availability.currency) || currency,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
return translateAvailability({
|
|
436
|
+
rootValue,
|
|
437
|
+
typeDefs: availTypeDefs,
|
|
438
|
+
query: availQuery,
|
|
439
|
+
variableValues: {
|
|
440
|
+
productId: rootValue.productId,
|
|
441
|
+
optionId: rootValue.rateId,
|
|
442
|
+
currency: rootValue.currency,
|
|
443
|
+
unitsWithQuantity: units,
|
|
444
|
+
jwtKey,
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
}, { concurrency: CONCURRENCY });
|
|
448
|
+
|
|
449
|
+
return translated.filter(Boolean);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
console.error(`Error fetching availability for product ${productId}:`, (err.response && err.response.data) || err.message);
|
|
452
|
+
return [];
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
class Plugin {
|
|
457
|
+
constructor(params) {
|
|
458
|
+
Object.entries(params).forEach(([attr, value]) => {
|
|
459
|
+
this[attr] = value;
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// Use environment variables for testing if not provided
|
|
463
|
+
this.apiKey = this.apiKey || process.env.ti2_bokun_apiKey;
|
|
464
|
+
this.jwtKey = this.jwtKey
|
|
465
|
+
|| process.env.TI2_BOKUN_JWT_KEY
|
|
466
|
+
|| crypto.randomBytes(24).toString('hex');
|
|
467
|
+
|
|
468
|
+
this.tokenTemplate = () => ({
|
|
469
|
+
apiKey: {
|
|
470
|
+
type: 'text',
|
|
471
|
+
description: 'Bokun API key (Bearer token for OCTO API)',
|
|
472
|
+
},
|
|
473
|
+
endpoint: {
|
|
474
|
+
type: 'text',
|
|
475
|
+
// eslint-disable-next-line max-len
|
|
476
|
+
regExp: /^(?!mailto:)(?:(?:http|https|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[0-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))|localhost)(?::\d{2,5})?(?:(\/|\?|#)[^\s]*)?$/i,
|
|
477
|
+
default: 'https://api.bokun.io/octo/v1',
|
|
478
|
+
description: 'The Bokun API endpoint (use https://api.bokuntest.com/octo/v1 for testing)',
|
|
479
|
+
},
|
|
480
|
+
tenant: {
|
|
481
|
+
type: 'text',
|
|
482
|
+
// eslint-disable-next-line max-len
|
|
483
|
+
description: 'Bokun tenant subdomain for booking URL (e.g. tourconnect-llc). Enables privateUrl in booking information.',
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
this.errorPathsAxiosErrors = () => ([
|
|
488
|
+
['response', 'data', 'message'],
|
|
489
|
+
['response', 'data', 'error'],
|
|
490
|
+
]);
|
|
491
|
+
this.errorPathsAxiosAny = () => ([]);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async validateToken({
|
|
495
|
+
axios,
|
|
496
|
+
token: {
|
|
497
|
+
endpoint,
|
|
498
|
+
apiKey,
|
|
499
|
+
},
|
|
500
|
+
}) {
|
|
501
|
+
assert(apiKey, 'apiKey is required for token validation');
|
|
502
|
+
assert(endpoint, 'endpoint is required for token validation');
|
|
503
|
+
|
|
504
|
+
const baseUrl = (endpoint || this.endpoint || 'https://api.bokun.io/octo/v1');
|
|
505
|
+
const url = `${baseUrl}/supplier`;
|
|
506
|
+
const headers = getHeaders({ apiKey });
|
|
507
|
+
try {
|
|
508
|
+
const response = await axios({
|
|
509
|
+
method: 'get',
|
|
510
|
+
url,
|
|
511
|
+
headers,
|
|
512
|
+
});
|
|
513
|
+
// API returns a supplier object { id, name, endpoint, contact }, not an array
|
|
514
|
+
const { data } = response;
|
|
515
|
+
return Boolean(
|
|
516
|
+
data
|
|
517
|
+
&& typeof data === 'object'
|
|
518
|
+
&& !Array.isArray(data)
|
|
519
|
+
&& (data.id != null || data.name != null),
|
|
520
|
+
);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
console.error('Token validation error:', (err.response && err.response.data) || err.message);
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async searchProducts({
|
|
528
|
+
axios,
|
|
529
|
+
token: {
|
|
530
|
+
apiKey,
|
|
531
|
+
endpoint,
|
|
532
|
+
},
|
|
533
|
+
typeDefsAndQueries: {
|
|
534
|
+
productTypeDefs,
|
|
535
|
+
productQuery,
|
|
536
|
+
},
|
|
537
|
+
}) {
|
|
538
|
+
assert(apiKey, 'apiKey is required for search products');
|
|
539
|
+
assert(endpoint, 'endpoint is required for search products');
|
|
540
|
+
|
|
541
|
+
const baseUrl = (endpoint || this.endpoint || 'https://api.bokun.io/octo/v1');
|
|
542
|
+
const url = `${baseUrl}/products`;
|
|
543
|
+
const headers = getHeaders({ apiKey });
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
const response = await axios({ method: 'get', url, headers });
|
|
547
|
+
|
|
548
|
+
let products = R.path(['data', 'products'], response) || R.path(['data'], response) || [];
|
|
549
|
+
|
|
550
|
+
if (!Array.isArray(products)) products = [products];
|
|
551
|
+
|
|
552
|
+
const translatedProducts = await Promise.map(products, async product => {
|
|
553
|
+
const translatedProduct = await translateProduct({
|
|
554
|
+
rootValue: product,
|
|
555
|
+
typeDefs: productTypeDefs,
|
|
556
|
+
query: productQuery,
|
|
557
|
+
});
|
|
558
|
+
return ({
|
|
559
|
+
...translatedProduct,
|
|
560
|
+
supplierReference: product.id != null ? product.id.toString() : undefined,
|
|
561
|
+
});
|
|
562
|
+
}, { concurrency: CONCURRENCY });
|
|
563
|
+
|
|
564
|
+
return ({ products: translatedProducts });
|
|
565
|
+
} catch (err) {
|
|
566
|
+
console.error('Error searching products:', (err.response && err.response.data) || err.message);
|
|
567
|
+
return ({ products: [] });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async searchAvailability({
|
|
572
|
+
axios,
|
|
573
|
+
token: {
|
|
574
|
+
apiKey,
|
|
575
|
+
endpoint,
|
|
576
|
+
},
|
|
577
|
+
payload: {
|
|
578
|
+
productIds,
|
|
579
|
+
optionIds,
|
|
580
|
+
units,
|
|
581
|
+
startDate,
|
|
582
|
+
endDate,
|
|
583
|
+
dateFormat,
|
|
584
|
+
currency,
|
|
585
|
+
},
|
|
586
|
+
typeDefsAndQueries: {
|
|
587
|
+
availTypeDefs,
|
|
588
|
+
availQuery,
|
|
589
|
+
},
|
|
590
|
+
}) {
|
|
591
|
+
assert(apiKey, 'apiKey is required for search availability');
|
|
592
|
+
assert(endpoint, 'endpoint is required for search availability');
|
|
593
|
+
assert(this.jwtKey, 'jwtKey is required for search availability');
|
|
594
|
+
|
|
595
|
+
assert(Array.isArray(productIds) && productIds.length > 0, 'productIds are required');
|
|
596
|
+
const startMoment = moment(startDate, dateFormat).startOf('day');
|
|
597
|
+
const today = moment().startOf('day');
|
|
598
|
+
const effectiveStartMoment = startMoment.isBefore(today) ? today : startMoment;
|
|
599
|
+
const normalisedOptionIds = normaliseOptionIds(productIds, optionIds);
|
|
600
|
+
const normalisedUnits = normaliseUnits(productIds, units);
|
|
601
|
+
const baseUrl = (endpoint || this.endpoint || 'https://api.bokun.io/octo/v1');
|
|
602
|
+
const startDateFormatted = effectiveStartMoment.format('YYYY-MM-DD');
|
|
603
|
+
const endDateFormatted = moment(endDate, dateFormat).format('YYYY-MM-DD');
|
|
604
|
+
const availabilityByProduct = await Promise.map(productIds, async (productId, index) => {
|
|
605
|
+
const optionId = normalisedOptionIds[index];
|
|
606
|
+
const productUnits = normalisedUnits[index] || [];
|
|
607
|
+
|
|
608
|
+
return fetchAvailabilityForProduct({
|
|
609
|
+
axios,
|
|
610
|
+
apiKey,
|
|
611
|
+
baseUrl,
|
|
612
|
+
productId,
|
|
613
|
+
startDate: startDateFormatted,
|
|
614
|
+
endDate: endDateFormatted,
|
|
615
|
+
currency,
|
|
616
|
+
optionId,
|
|
617
|
+
units: productUnits,
|
|
618
|
+
availTypeDefs,
|
|
619
|
+
availQuery,
|
|
620
|
+
jwtKey: this.jwtKey,
|
|
621
|
+
});
|
|
622
|
+
}, { concurrency: CONCURRENCY });
|
|
623
|
+
|
|
624
|
+
return ({ availability: availabilityByProduct });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async availabilityCalendar({
|
|
628
|
+
axios,
|
|
629
|
+
token: {
|
|
630
|
+
endpoint,
|
|
631
|
+
apiKey,
|
|
632
|
+
},
|
|
633
|
+
payload: {
|
|
634
|
+
productIds,
|
|
635
|
+
optionIds,
|
|
636
|
+
units,
|
|
637
|
+
startDate,
|
|
638
|
+
endDate,
|
|
639
|
+
currency,
|
|
640
|
+
dateFormat,
|
|
641
|
+
},
|
|
642
|
+
typeDefsAndQueries: {
|
|
643
|
+
availTypeDefs,
|
|
644
|
+
availQuery,
|
|
645
|
+
},
|
|
646
|
+
}) {
|
|
647
|
+
// Bokun API doesn't support calendar availability, so we need to use the
|
|
648
|
+
// search availability endpoint instead.
|
|
649
|
+
return this.searchAvailability({
|
|
650
|
+
axios,
|
|
651
|
+
token: { apiKey, endpoint },
|
|
652
|
+
payload: { productIds, optionIds, units, startDate, endDate, dateFormat, currency },
|
|
653
|
+
typeDefsAndQueries: { availTypeDefs, availQuery },
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async getCreateBookingFields({ axios, token: { apiKey, endpoint }, query: { productId } }) {
|
|
658
|
+
const fields = [
|
|
659
|
+
{ id: 'firstName', title: 'First name', type: 'short', required: true },
|
|
660
|
+
{ id: 'lastName', title: 'Last name', type: 'short', required: true },
|
|
661
|
+
{ id: 'emailAddress', title: 'Email', type: 'short', required: true },
|
|
662
|
+
{ id: 'phoneNumber', title: 'Phone', type: 'short', required: false },
|
|
663
|
+
{ id: 'country', title: 'Country', type: 'short', required: false },
|
|
664
|
+
{ id: 'postalCode', title: 'Postal code', type: 'short', required: false },
|
|
665
|
+
];
|
|
666
|
+
let customFields = [];
|
|
667
|
+
const baseUrl = (endpoint || this.endpoint || 'https://api.bokun.io/octo/v1');
|
|
668
|
+
if (productId && apiKey && axios) {
|
|
669
|
+
try {
|
|
670
|
+
const productRes = await axios.get(`${baseUrl}/products/${productId}`, { headers: getHeaders({ apiKey }) });
|
|
671
|
+
const product = productRes && productRes.data;
|
|
672
|
+
const options = (product && product.options) || [];
|
|
673
|
+
const units = options.flatMap(opt => ((opt.units || []).map(u => ({ ...u, isPerUnitItem: true }))));
|
|
674
|
+
const allWithQuestions = [...options, ...units].filter(o => o.questions && o.questions.length);
|
|
675
|
+
const flattened = allWithQuestions.flatMap(
|
|
676
|
+
obj => (obj.questions || []).map(q => ({ ...q, isPerUnitItem: Boolean(obj.isPerUnitItem) })),
|
|
677
|
+
);
|
|
678
|
+
const byId = new Map();
|
|
679
|
+
flattened.forEach(q => {
|
|
680
|
+
const existing = byId.get(q.id);
|
|
681
|
+
const merged = !existing ? q : {
|
|
682
|
+
...existing,
|
|
683
|
+
...q,
|
|
684
|
+
isPerUnitItem: existing.isPerUnitItem || q.isPerUnitItem,
|
|
685
|
+
};
|
|
686
|
+
byId.set(q.id, merged);
|
|
687
|
+
});
|
|
688
|
+
customFields = Array.from(byId.values()).map(q => ({
|
|
689
|
+
id: String(q.id),
|
|
690
|
+
title: q.title || q.label || q.name || String(q.id),
|
|
691
|
+
type: (q.type === 'TEXTAREA' || q.inputType === 'textarea') ? 'long' : 'short',
|
|
692
|
+
required: Boolean(q.required),
|
|
693
|
+
}));
|
|
694
|
+
} catch (err) {
|
|
695
|
+
console.error('getCreateBookingFields: could not load product questions:', err.message);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return { fields, customFields };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async createBooking({
|
|
702
|
+
axios,
|
|
703
|
+
token: {
|
|
704
|
+
apiKey,
|
|
705
|
+
endpoint,
|
|
706
|
+
tenant,
|
|
707
|
+
},
|
|
708
|
+
payload: {
|
|
709
|
+
availabilityKey,
|
|
710
|
+
holder,
|
|
711
|
+
notes,
|
|
712
|
+
reference,
|
|
713
|
+
product,
|
|
714
|
+
unitItems,
|
|
715
|
+
currency,
|
|
716
|
+
},
|
|
717
|
+
typeDefsAndQueries: {
|
|
718
|
+
bookingTypeDefs,
|
|
719
|
+
bookingQuery,
|
|
720
|
+
},
|
|
721
|
+
}) {
|
|
722
|
+
assert(apiKey, 'apiKey is required for booking creation');
|
|
723
|
+
assert(endpoint, 'endpoint is required for booking creation');
|
|
724
|
+
assert(tenant, 'tenant is required for booking creation');
|
|
725
|
+
assert(this.jwtKey, 'JWT secret should be set for availability keys');
|
|
726
|
+
assert(holder.name, 'holder.name is required for booking creation');
|
|
727
|
+
|
|
728
|
+
const baseUrl = (endpoint || this.endpoint || 'https://api.bokun.io/octo/v1');
|
|
729
|
+
let availabilityMetadata = {};
|
|
730
|
+
|
|
731
|
+
if (availabilityKey) {
|
|
732
|
+
try {
|
|
733
|
+
availabilityMetadata = jwt.verify(availabilityKey, this.jwtKey);
|
|
734
|
+
} catch (err) {
|
|
735
|
+
console.error('Error decoding availability key:', err.message);
|
|
736
|
+
throw new Error('Invalid availabilityKey');
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Extract required OCTO fields from availability metadata
|
|
741
|
+
const productId = (product && product.productId) || availabilityMetadata.productId;
|
|
742
|
+
const optionId = availabilityMetadata.optionId || availabilityMetadata.rateId;
|
|
743
|
+
const availabilityId = availabilityMetadata.availabilityId || availabilityMetadata.startTimeId;
|
|
744
|
+
|
|
745
|
+
if (!productId) {
|
|
746
|
+
throw new Error('productId is required for booking creation');
|
|
747
|
+
}
|
|
748
|
+
if (!optionId) {
|
|
749
|
+
throw new Error('optionId is required for booking creation');
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Build unitItems according to OCTO BookingUnitItem schema
|
|
753
|
+
const keyUnitItems = Array.isArray(availabilityMetadata.unitItems) ? availabilityMetadata.unitItems : [];
|
|
754
|
+
const finalUnitItems = Array.isArray(unitItems) && unitItems.length > 0
|
|
755
|
+
? unitItems
|
|
756
|
+
: keyUnitItems;
|
|
757
|
+
|
|
758
|
+
// Transform unitItems to OCTO format: { unitId, uuid? }
|
|
759
|
+
const octoUnitItems = finalUnitItems.map(item => ({
|
|
760
|
+
unitId: item.unitId,
|
|
761
|
+
...(item.uuid ? { uuid: item.uuid } : {}),
|
|
762
|
+
}));
|
|
763
|
+
|
|
764
|
+
if (octoUnitItems.length === 0) {
|
|
765
|
+
throw new Error('At least one unitItem is required for booking creation');
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const bookingCurrency = currency
|
|
769
|
+
|| availabilityMetadata.currency
|
|
770
|
+
|| 'USD';
|
|
771
|
+
|
|
772
|
+
// Build OCTO BookingReservationBody per docs.octo.travel/octo-api-core/bookings
|
|
773
|
+
// Note: uuid (idempotency) and resellerReference are NOT in the reservation spec.
|
|
774
|
+
// resellerReference is sent in the confirm step only (POST /bookings/{uuid}/confirm).
|
|
775
|
+
const bookingReservationBody = {
|
|
776
|
+
productId,
|
|
777
|
+
optionId,
|
|
778
|
+
unitItems: octoUnitItems,
|
|
779
|
+
...(availabilityId ? { availabilityId } : {}),
|
|
780
|
+
...(notes ? { notes } : {}),
|
|
781
|
+
...(bookingCurrency ? { currency: bookingCurrency } : {}),
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
// OCTO API endpoint: POST /api/octo/bookings/
|
|
785
|
+
// According to OCTO spec, the endpoint is /api/octo/bookings/ relative to base URL
|
|
786
|
+
// If TI2 adds /octo/v1 prefix, adjust path accordingly
|
|
787
|
+
const path = '/bookings';
|
|
788
|
+
const url = `${baseUrl}${path}`;
|
|
789
|
+
const headers = getHeaders({
|
|
790
|
+
apiKey,
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
try {
|
|
794
|
+
// Step 1: Create booking reservation (status: ON_HOLD)
|
|
795
|
+
// According to OCTO spec: POST /bookings/ creates a reservation
|
|
796
|
+
const bookingResponse = await axios({
|
|
797
|
+
method: 'post',
|
|
798
|
+
url,
|
|
799
|
+
headers,
|
|
800
|
+
data: bookingReservationBody,
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
if (bookingResponse.status < 200 || bookingResponse.status >= 300) {
|
|
804
|
+
const errorData = JSON.stringify(bookingResponse.data);
|
|
805
|
+
throw new Error(
|
|
806
|
+
`Booking reservation failed with status ${bookingResponse.status}: ${errorData}`,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const responseData = bookingResponse.data;
|
|
811
|
+
if (typeof responseData === 'string' && responseData.includes('<!DOCTYPE html>')) {
|
|
812
|
+
const htmlPreview = responseData.substring(0, 500);
|
|
813
|
+
console.error('Booking reservation returned HTML instead of JSON. Response:', htmlPreview);
|
|
814
|
+
throw new Error(
|
|
815
|
+
'Booking reservation failed: API returned HTML (likely authentication error or wrong endpoint)',
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
let booking = R.path(['data'], bookingResponse) || responseData;
|
|
820
|
+
|
|
821
|
+
if (!booking) {
|
|
822
|
+
console.error('Booking reservation response status:', bookingResponse.status);
|
|
823
|
+
console.error('Booking reservation response headers:', bookingResponse.headers);
|
|
824
|
+
const dataPreview = typeof responseData === 'string'
|
|
825
|
+
? responseData.substring(0, 500)
|
|
826
|
+
: JSON.stringify(responseData, null, 2);
|
|
827
|
+
console.error('Booking reservation response data:', dataPreview);
|
|
828
|
+
throw new Error('Failed to create booking reservation: no booking data returned from API');
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Step 2: Confirm booking (if holder information is provided)
|
|
832
|
+
// According to OCTO spec: POST /bookings/{uuid}/confirm confirms the booking
|
|
833
|
+
if (holder && (holder.emailAddress || holder.name || holder.surname)) {
|
|
834
|
+
const bookingUuid = booking.uuid || booking.id;
|
|
835
|
+
if (!bookingUuid) {
|
|
836
|
+
console.warn('Booking created but no UUID found for confirmation');
|
|
837
|
+
} else {
|
|
838
|
+
const confirmPath = `/bookings/${bookingUuid}/confirm`;
|
|
839
|
+
const confirmUrl = `${baseUrl}${confirmPath}`;
|
|
840
|
+
const confirmHeaders = getHeaders({
|
|
841
|
+
apiKey,
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
// Build BookingConfirmationBody according to OCTO spec
|
|
845
|
+
const bookingConfirmationBody = {
|
|
846
|
+
contact: {
|
|
847
|
+
...(holder.name ? { firstName: holder.name } : {}),
|
|
848
|
+
...(holder.surname ? { lastName: holder.surname } : {}),
|
|
849
|
+
...(holder.emailAddress ? { emailAddress: holder.emailAddress } : {}),
|
|
850
|
+
...(holder.phoneNumber ? { phoneNumber: holder.phoneNumber } : {}),
|
|
851
|
+
...(holder.country ? { country: holder.country } : {}),
|
|
852
|
+
...(holder.postalCode ? { postalCode: holder.postalCode } : {}),
|
|
853
|
+
...(holder.locales ? {
|
|
854
|
+
locales: Array.isArray(holder.locales) ? holder.locales : [holder.locales],
|
|
855
|
+
} : {}),
|
|
856
|
+
...(notes ? { notes } : {}),
|
|
857
|
+
},
|
|
858
|
+
...(reference ? { resellerReference: reference } : {}),
|
|
859
|
+
// Include unitItems if they need contact information per unit
|
|
860
|
+
...(octoUnitItems.length > 0 ? {
|
|
861
|
+
unitItems: octoUnitItems.map(item => ({
|
|
862
|
+
unitId: item.unitId,
|
|
863
|
+
...(item.uuid ? { uuid: item.uuid } : {}),
|
|
864
|
+
})),
|
|
865
|
+
} : {}),
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
try {
|
|
869
|
+
const confirmResponse = await axios({
|
|
870
|
+
method: 'post',
|
|
871
|
+
url: confirmUrl,
|
|
872
|
+
headers: confirmHeaders,
|
|
873
|
+
data: bookingConfirmationBody,
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
if (confirmResponse.status >= 200 && confirmResponse.status < 300) {
|
|
877
|
+
const confirmedBooking = R.path(['data'], confirmResponse) || confirmResponse.data;
|
|
878
|
+
if (confirmedBooking) {
|
|
879
|
+
booking = confirmedBooking;
|
|
880
|
+
}
|
|
881
|
+
} else {
|
|
882
|
+
const { status } = confirmResponse;
|
|
883
|
+
console.warn(
|
|
884
|
+
`Booking confirmation failed with status ${status}, but reservation was created`,
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
} catch (confirmErr) {
|
|
888
|
+
const errorMsg = (confirmErr.response && confirmErr.response.data) || confirmErr.message;
|
|
889
|
+
console.warn('Error confirming booking, but reservation was created:', errorMsg);
|
|
890
|
+
// Don't throw - reservation was successfully created, confirmation can be retried later
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const baseUrlForSales = endpoint || this.endpoint || 'https://api.bokun.io/octo/v1';
|
|
896
|
+
const uiBaseUrl = getSalesBaseUrlFromEndpoint(baseUrlForSales, tenant || process.env.TI2_BOKUN_TENANT);
|
|
897
|
+
const translatedBooking = await translateBooking({
|
|
898
|
+
rootValue: { ...booking, uiBaseUrl },
|
|
899
|
+
typeDefs: bookingTypeDefs,
|
|
900
|
+
query: bookingQuery,
|
|
901
|
+
});
|
|
902
|
+
return ({ booking: translatedBooking });
|
|
903
|
+
} catch (err) {
|
|
904
|
+
console.error('Error creating booking:', (err.response && err.response.data) || err.message);
|
|
905
|
+
throw err;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// TODO: Verify with Bokun team on whether they have implemented this endpoint
|
|
910
|
+
// async cancelBooking({
|
|
911
|
+
// axios,
|
|
912
|
+
// token: {
|
|
913
|
+
// apiKey,
|
|
914
|
+
// endpoint,
|
|
915
|
+
// tenant,
|
|
916
|
+
// },
|
|
917
|
+
// payload: {
|
|
918
|
+
// bookingId,
|
|
919
|
+
// id,
|
|
920
|
+
// reason,
|
|
921
|
+
// },
|
|
922
|
+
// typeDefsAndQueries: {
|
|
923
|
+
// bookingTypeDefs,
|
|
924
|
+
// bookingQuery,
|
|
925
|
+
// },
|
|
926
|
+
// }) {
|
|
927
|
+
// assert(apiKey, 'apiKey is required for booking cancellation');
|
|
928
|
+
// assert(endpoint, 'endpoint is required for booking cancellation');
|
|
929
|
+
// assert(tenant, 'tenant is required for booking cancellation');
|
|
930
|
+
|
|
931
|
+
// const baseUrl = (endpoint || this.endpoint || 'https://api.bokun.io/octo/v1');
|
|
932
|
+
// const bookingReference = bookingId || id;
|
|
933
|
+
// const path = `/booking.json/${bookingReference}/cancel`;
|
|
934
|
+
// const url = `${baseUrl}${path}`;
|
|
935
|
+
// const headers = getHeaders({
|
|
936
|
+
// apiKey,
|
|
937
|
+
// });
|
|
938
|
+
|
|
939
|
+
// try {
|
|
940
|
+
// const response = await axios({
|
|
941
|
+
// method: 'post',
|
|
942
|
+
// url,
|
|
943
|
+
// headers,
|
|
944
|
+
// data: {
|
|
945
|
+
// reason: reason || 'Customer requested cancellation',
|
|
946
|
+
// },
|
|
947
|
+
// });
|
|
948
|
+
|
|
949
|
+
// const cancelledBooking = R.path(['data'], response);
|
|
950
|
+
// const baseUrlForSales = endpoint || this.endpoint || 'https://api.bokun.io/octo/v1';
|
|
951
|
+
// const uiBaseUrl = getSalesBaseUrlFromEndpoint(baseUrlForSales, tenant || process.env.TI2_BOKUN_TENANT);
|
|
952
|
+
// const translatedBooking = await translateBooking({
|
|
953
|
+
// rootValue: {
|
|
954
|
+
// ...cancelledBooking,
|
|
955
|
+
// status: 'CANCELLED',
|
|
956
|
+
// uiBaseUrl,
|
|
957
|
+
// },
|
|
958
|
+
// typeDefs: bookingTypeDefs,
|
|
959
|
+
// query: bookingQuery,
|
|
960
|
+
// });
|
|
961
|
+
|
|
962
|
+
// return ({ cancellation: translatedBooking });
|
|
963
|
+
// } catch (err) {
|
|
964
|
+
// console.error('Error cancelling booking:', (err.response && err.response.data) || err.message);
|
|
965
|
+
// throw err;
|
|
966
|
+
// }
|
|
967
|
+
// }
|
|
968
|
+
|
|
969
|
+
async searchBooking({
|
|
970
|
+
axios,
|
|
971
|
+
token: {
|
|
972
|
+
apiKey,
|
|
973
|
+
endpoint,
|
|
974
|
+
tenant,
|
|
975
|
+
},
|
|
976
|
+
payload: {
|
|
977
|
+
bookingId,
|
|
978
|
+
travelDateStart,
|
|
979
|
+
dateFormat,
|
|
980
|
+
},
|
|
981
|
+
typeDefsAndQueries: {
|
|
982
|
+
bookingTypeDefs,
|
|
983
|
+
bookingQuery,
|
|
984
|
+
},
|
|
985
|
+
}) {
|
|
986
|
+
assert(apiKey, 'apiKey is required for booking search');
|
|
987
|
+
assert(endpoint, 'endpoint is required for booking search');
|
|
988
|
+
assert(tenant, 'tenant is required for booking search');
|
|
989
|
+
assert(
|
|
990
|
+
!isNilOrEmpty(bookingId) || !isNilOrEmpty(travelDateStart),
|
|
991
|
+
'at least one of bookingId or travelDateStart is required',
|
|
992
|
+
);
|
|
993
|
+
|
|
994
|
+
const baseUrl = (endpoint || this.endpoint || 'https://api.bokun.io/octo/v1');
|
|
995
|
+
const queryParams = new URLSearchParams();
|
|
996
|
+
if (bookingId) queryParams.append('supplierReference', bookingId);
|
|
997
|
+
|
|
998
|
+
if (travelDateStart) {
|
|
999
|
+
const format = dateFormat || 'YYYY-MM-DD';
|
|
1000
|
+
const travelDateFormatted = moment(travelDateStart, format).format('YYYY-MM-DD');
|
|
1001
|
+
queryParams.append('localDate', travelDateFormatted);
|
|
1002
|
+
}
|
|
1003
|
+
const path = `/bookings?${queryParams.toString()}`;
|
|
1004
|
+
const url = `${baseUrl}${path}`;
|
|
1005
|
+
const headers = getHeaders({ apiKey });
|
|
1006
|
+
try {
|
|
1007
|
+
const response = await axios({ method: 'get', url, headers });
|
|
1008
|
+
const data = R.path(['data'], response);
|
|
1009
|
+
const rawBookings = Array.isArray(data) ? data : [].concat(data || []);
|
|
1010
|
+
const baseUrlForSales = endpoint || this.endpoint || 'https://api.bokun.io/octo/v1';
|
|
1011
|
+
const uiBaseUrl = getSalesBaseUrlFromEndpoint(baseUrlForSales, tenant || process.env.TI2_BOKUN_TENANT);
|
|
1012
|
+
const translatedBookings = await Promise.map(rawBookings, async booking => translateBooking({
|
|
1013
|
+
rootValue: { ...booking, uiBaseUrl },
|
|
1014
|
+
typeDefs: bookingTypeDefs,
|
|
1015
|
+
query: bookingQuery,
|
|
1016
|
+
}));
|
|
1017
|
+
|
|
1018
|
+
return ({ bookings: translatedBookings });
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
console.error('Error searching booking:', (err.response && err.response.data) || err.message);
|
|
1021
|
+
return ({ bookings: [] });
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
module.exports = Plugin;
|