ti2-bokun 1.0.6 → 1.0.8

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/index.js CHANGED
@@ -4,10 +4,40 @@ const assert = require('assert');
4
4
  const moment = require('moment');
5
5
  const jwt = require('jsonwebtoken');
6
6
  const crypto = require('crypto');
7
+ const {
8
+ validateContactInfo,
9
+ getMainContactDetails,
10
+ normalizeMainContactAnswer,
11
+ expandRequiredCustomerQuestionIds,
12
+ extractCountryInputString,
13
+ COUNTRY_VALIDATION_ERROR,
14
+ PHONE_VALIDATION_ERROR,
15
+ } = require('./utils/customerInfoFields');
16
+ const {
17
+ buildCustomFieldsFromQuestions,
18
+ mapCustomFieldValuesToAnswers,
19
+ normalizeBookingQuestions,
20
+ normalizeQuestionAnswerList,
21
+ } = require('./utils/customFields');
22
+ const {
23
+ TRAVEL_DATE_FORMAT,
24
+ BOKUN_PASSENGER_FIELD_TO_UI_ID,
25
+ applyPassengerFieldsToUiFields,
26
+ validateRequiredContactFields,
27
+ normalizeCustomFieldValueForBooking,
28
+ extractRequiredQuestions,
29
+ } = require('./utils/bookingFields');
7
30
  const { translateProduct } = require('./resolvers/product');
8
31
  const { translateAvailability } = require('./resolvers/availability');
9
32
  const { translateBooking } = require('./resolvers/booking');
10
- const { translatePickupPoint } = require('./resolvers/pickup-point');
33
+ const {
34
+ fetchCountriesList,
35
+ } = require('./utils/country');
36
+ const {
37
+ buildRequestUrlAndPath,
38
+ getHeaders,
39
+ } = require('./utils/bokunRequest');
40
+ const { DEFAULT_EXCLUDED_CUSTOMER_QUESTION_IDS } = require('./utils/customerFieldConstants');
11
41
 
12
42
  const DIRECTBOOKING_SOURCE = 'DIRECT_REQUEST';
13
43
  const CHECKOUT_OPTION_DIRECTBOOKING = 'CUSTOMER_FULL_PAYMENT';
@@ -16,6 +46,19 @@ const PAYMENT_METHOD = 'RESERVE_FOR_EXTERNAL_PAYMENT';
16
46
 
17
47
  const CONCURRENCY = 3;
18
48
 
49
+ const COUNTRY_FIELD_IDS = new Set(['country', 'nationality']);
50
+
51
+ const normalizePassengerDetailValue = ({ questionId, value }) => {
52
+ if (value == null) return value;
53
+ const normalizedQuestionId = String(questionId || '').toLowerCase();
54
+ const isCountryLike = normalizedQuestionId === 'country' || normalizedQuestionId === 'nationality';
55
+ if (!isCountryLike || typeof value !== 'object' || Array.isArray(value)) return value;
56
+
57
+ if (value.label != null && String(value.label).trim() !== '') return String(value.label).trim();
58
+ if (value.value != null && String(value.value).trim() !== '') return String(value.value).trim();
59
+ return value;
60
+ };
61
+
19
62
  const emptyContact = () => ({
20
63
  fullName: null,
21
64
  firstName: null,
@@ -23,23 +66,11 @@ const emptyContact = () => ({
23
66
  emailAddress: null,
24
67
  phoneNumber: null,
25
68
  locales: [],
26
- postalCode: null,
69
+ postcode: null,
27
70
  country: null,
28
71
  notes: null,
29
72
  });
30
73
 
31
- /** Bokun requires a non-empty, plausible email on booking; surface clear errors to the UI. */
32
- const validateBookingEmail = raw => {
33
- const trimmed = raw == null ? '' : String(raw).trim();
34
- if (trimmed === '') {
35
- return { ok: false, error: 'Email is required. Please enter a valid email address.' };
36
- }
37
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
38
- return { ok: false, error: 'Please enter a valid email address.' };
39
- }
40
- return { ok: true, email: trimmed };
41
- };
42
-
43
74
  /**
44
75
  * Build UI-format unitItems from Bokun booking response.
45
76
  * Matches pricingCategories.id / pricePerCategoryUnit.id to show pricingCategories.title as unitName.
@@ -491,6 +522,33 @@ const toRetailMinor = (price, currencyPrecision = 2) => {
491
522
  return Math.round(p * (10 ** (Number.isNaN(prec) ? 2 : prec)));
492
523
  };
493
524
 
525
+ const extractBokunErrorMessage = err => {
526
+ const responseData = err && err.response && err.response.data;
527
+ if (!responseData) return err && err.message;
528
+ if (typeof responseData === 'string') return responseData;
529
+ if (responseData.errorMessage) return responseData.errorMessage;
530
+ if (responseData.message) return responseData.message;
531
+ if (responseData.error) {
532
+ if (typeof responseData.error === 'string') return responseData.error;
533
+ if (responseData.error.message) return responseData.error.message;
534
+ }
535
+ if (Array.isArray(responseData.errors) && responseData.errors.length > 0) {
536
+ return responseData.errors
537
+ .map(entry => {
538
+ if (!entry) return null;
539
+ if (typeof entry === 'string') return entry;
540
+ return entry.message || null;
541
+ })
542
+ .filter(Boolean)
543
+ .join(', ');
544
+ }
545
+ if (responseData.details) {
546
+ if (typeof responseData.details === 'string') return responseData.details;
547
+ return JSON.stringify(responseData.details);
548
+ }
549
+ return err && err.message;
550
+ };
551
+
494
552
  /**
495
553
  * Shape a single availability slot to the UI-expected format.
496
554
  * unitId is always string across product, availability, and booking.
@@ -677,6 +735,7 @@ const buildAvailabilityRoot = ({
677
735
  currency,
678
736
  fallbackDate,
679
737
  pricingCategoryLookup,
738
+ pickupPoints = [],
680
739
  }) => {
681
740
  const availabilityDate = parseAvailabilityDate(availability);
682
741
  const fallbackLocalDate = typeof fallbackDate === 'string'
@@ -758,8 +817,9 @@ const buildAvailabilityRoot = ({
758
817
  currency: fallbackCurrency,
759
818
  offer: availability.offer || availability.offers || null,
760
819
  offers: availability.offers,
761
- pickupAvailable: availability.pickupAvailable || availability.pickupAllotment || false,
820
+ pickupAvailable: pickupPoints.length > 0 || availability.pickupAvailable || availability.pickupAllotment || false,
762
821
  pickupRequired: availability.pickupRequired || false,
822
+ pickupPoints,
763
823
  pricingFrom: resolvedPricing,
764
824
  unitPricingFrom: unitPricing,
765
825
  ratePricing: availability.ratePricing,
@@ -822,74 +882,32 @@ const deriveStableJwtKey = ({ accessKey, secretKey }) => {
822
882
  };
823
883
 
824
884
  /**
825
- * Build full request URL and the path to use for signature (path the server sees).
826
- * If baseUrl is https://api.bokun.io/rest-v2, pathForSigning must be /rest-v2/activity.json/... so the signature matches.
885
+ * Map a Bokun `startPoints[]` entry (from the product payload) to the
886
+ * canonical pickup-point shape used by the availability slot and the
887
+ * pickup-point GraphQL resolver.
888
+ *
889
+ * startPoint field → pickup point field
890
+ * id (number) → id (string)
891
+ * title → name
892
+ * type → pickupType
893
+ * address.addressLine1 + city → address (human-readable string)
894
+ * address.geoPoint.latitude/lng → latitude / longitude
895
+ * pickupTicketDescription → description
827
896
  */
828
- const buildRequestUrlAndPath = (baseUrl, path) => {
829
- const pathPart = path.startsWith('/') ? path : `/${path}`;
830
- const url = `${baseUrl.replace(/\/$/, '')}${pathPart}`;
831
- let pathForSigning = pathPart;
832
- try {
833
- const u = new URL(url);
834
- pathForSigning = u.pathname + u.search;
835
- } catch (_) {
836
- // fallback: use pathPart if URL parse fails
837
- }
838
- return { url, pathForSigning };
839
- };
840
-
841
- /**
842
- * Bókun signature per https://bokun.dev/booking-api-rest/vU6sCfxwYdJWd1QAcLt12i/configuring-the-platform-for-api-usage-and-authentication/sFiGRpo4detkmrZPcWtQPj
843
- * String to sign = date + accessKey + HTTP method (uppercase) + path (including query string, no scheme/host).
844
- * Then HMAC-SHA1 with secret key, then Base64.
845
- */
846
- const generateBokunSignature = ({
847
- secretKey,
848
- accessKey,
849
- date,
850
- method,
851
- path,
852
- }) => {
853
- const stringToSign = `${date}${accessKey}${method.toUpperCase()}${path}`;
854
- const signature = crypto
855
- .createHmac('sha1', secretKey)
856
- .update(stringToSign)
857
- .digest('base64');
858
- return signature;
859
- };
860
-
861
- const getHeaders = ({
862
- accessKey,
863
- secretKey,
864
- method,
865
- path,
866
- }) => {
867
- if (typeof secretKey !== 'string' || !secretKey) {
868
- throw new Error(
869
- 'Bokun API secretKey is required. Configure accessKey and secretKey in the integration token '
870
- + 'or set ti2_bokun_accessKey and ti2_bokun_secretKey.',
871
- );
872
- }
873
- if (typeof accessKey !== 'string' || !accessKey) {
874
- throw new Error(
875
- 'Bokun API accessKey is required. Configure accessKey and secretKey in the integration token '
876
- + 'or set ti2_bokun_accessKey and ti2_bokun_secretKey.',
877
- );
878
- }
879
- const date = moment.utc().format('YYYY-MM-DD HH:mm:ss');
880
- const signature = generateBokunSignature({
881
- secretKey,
882
- accessKey,
883
- date,
884
- method,
885
- path,
886
- });
887
-
897
+ const mapStartPointToPickupPoint = point => {
898
+ if (!point) return null;
899
+ const addr = point.address || {};
900
+ const geoPoint = addr.geoPoint || {};
901
+ const addressParts = [addr.addressLine1, addr.city, addr.state, addr.countryCode].filter(Boolean);
888
902
  return {
889
- 'X-Bokun-Date': date,
890
- 'X-Bokun-AccessKey': accessKey,
891
- 'X-Bokun-Signature': signature,
892
- 'Content-Type': 'application/json;charset=UTF-8',
903
+ id: point.id != null ? String(point.id) : null,
904
+ name: point.title || null,
905
+ description: point.pickupTicketDescription || null,
906
+ address: addressParts.length > 0 ? addressParts.join(', ') : null,
907
+ latitude: geoPoint.latitude ?? null,
908
+ longitude: geoPoint.longitude ?? null,
909
+ pickupType: point.type || null,
910
+ directions: null,
893
911
  };
894
912
  };
895
913
 
@@ -931,9 +949,16 @@ const fetchAvailabilityForProduct = async ({
931
949
  url: productUrl,
932
950
  headers: productHeaders,
933
951
  });
934
- pricingCategoryLookup = buildPricingCategoryLookup(R.pathOr({}, ['data'], productResponse));
952
+ const productData = R.pathOr({}, ['data'], productResponse);
953
+ pricingCategoryLookup = buildPricingCategoryLookup(productData);
954
+ const showPickupPlaces = Boolean(productData.displaySettings?.showPickupPlaces);
955
+ const pickupPoints = showPickupPlaces
956
+ ? (Array.isArray(productData.startPoints) ? productData.startPoints : [])
957
+ .map(mapStartPointToPickupPoint)
958
+ .filter(Boolean)
959
+ : [];
935
960
 
936
- // Get availability details
961
+ // Fetch availability
937
962
  const queryString = queryParts.join('&');
938
963
  const path = `/activity.json/${productId}/availabilities?${queryString}`;
939
964
  const { url, pathForSigning } = buildRequestUrlAndPath(baseUrl, path);
@@ -943,11 +968,7 @@ const fetchAvailabilityForProduct = async ({
943
968
  method: 'GET',
944
969
  path: pathForSigning,
945
970
  });
946
- const availabilityResponse = await axios({
947
- method: 'get',
948
- url,
949
- headers,
950
- });
971
+ const availabilityResponse = await axios({ method: 'get', url, headers });
951
972
  const availabilities = R.pathOr([], ['data'], availabilityResponse);
952
973
  const translated = await Promise.map(availabilities, async availability => {
953
974
  const rateMatch = findRateMatch({
@@ -984,6 +1005,7 @@ const fetchAvailabilityForProduct = async ({
984
1005
  currency: rateCurrency || currency,
985
1006
  fallbackDate: startDate,
986
1007
  pricingCategoryLookup,
1008
+ pickupPoints,
987
1009
  });
988
1010
 
989
1011
  const translatedAvailability = await translateAvailability({
@@ -1032,6 +1054,204 @@ const fetchAvailabilityForProduct = async ({
1032
1054
  }
1033
1055
  };
1034
1056
 
1057
+ // Apply the live (or fallback) Bokun country list as the option set on the
1058
+ // `country` / `nationality` UI fields. NOTE: this is an authoritative override
1059
+ // — any pre-existing `options` / `type` on those fields (e.g. carried over
1060
+ // from a Bokun bookingQuestion) are intentionally replaced so the dropdown
1061
+ // always renders Bokun's canonical country list.
1062
+ const decorateCountryFields = (fieldList, options) => {
1063
+ if (!Array.isArray(fieldList)) return fieldList;
1064
+ if (!Array.isArray(options) || options.length === 0) return fieldList;
1065
+ return fieldList.map(field => {
1066
+ const normalizedFieldId = String(field?.id || '').toLowerCase();
1067
+ if (!field || !COUNTRY_FIELD_IDS.has(normalizedFieldId)) return field;
1068
+ return {
1069
+ ...field,
1070
+ type: 'extended-option',
1071
+ selectMultiple: false,
1072
+ options,
1073
+ };
1074
+ });
1075
+ };
1076
+
1077
+ const getContactValidationErrors = contactInfo => (
1078
+ Array.isArray(contactInfo?.errors) && contactInfo.errors.length > 0
1079
+ ? contactInfo.errors
1080
+ : [contactInfo?.error || 'Contact information is required.']
1081
+ );
1082
+
1083
+ const shouldRetryContactValidationWithDynamicCountries = (contactInfo, holder) => {
1084
+ if (!contactInfo || contactInfo.ok) return false;
1085
+ const hasCountryInput = Boolean(
1086
+ holder
1087
+ && (
1088
+ extractCountryInputString(holder.country) !== ''
1089
+ || extractCountryInputString(holder.nationality) !== ''
1090
+ ),
1091
+ );
1092
+ if (!hasCountryInput) return false;
1093
+
1094
+ const errors = getContactValidationErrors(contactInfo);
1095
+ return errors.some(e => e === COUNTRY_VALIDATION_ERROR)
1096
+ && errors.every(e => e === COUNTRY_VALIDATION_ERROR || e === PHONE_VALIDATION_ERROR);
1097
+ };
1098
+
1099
+ const normalizePassengerFields = passengerFields => (
1100
+ (Array.isArray(passengerFields) ? passengerFields : [])
1101
+ .map(entry => {
1102
+ if (!entry || typeof entry !== 'object') return null;
1103
+ const fieldCode = typeof entry.field === 'string' ? entry.field.trim() : '';
1104
+ if (!fieldCode) return null;
1105
+ const normalizedFieldCode = fieldCode.toUpperCase().replace(/[^A-Z0-9]/g, '');
1106
+ return {
1107
+ field: BOKUN_PASSENGER_FIELD_TO_UI_ID[normalizedFieldCode] || fieldCode.toLowerCase(),
1108
+ required: Boolean(entry.required),
1109
+ };
1110
+ })
1111
+ .filter(Boolean)
1112
+ );
1113
+
1114
+ async function fetchMandatoryBookingQuestionsForProduct(plugin, {
1115
+ axios,
1116
+ token,
1117
+ productId,
1118
+ }) {
1119
+ try {
1120
+ if (!productId) return { leadPassengerContactInfo: [], passengerContactInfo: [], bookingQuestions: [] };
1121
+
1122
+ const { endpoint, testMode } = token || {};
1123
+ const { accessKey, secretKey } = plugin.getCredentials(token);
1124
+ const baseUrl = testMode ? 'https://api.bokuntest.com' : (endpoint || plugin.endpoint || 'https://api.bokun.io');
1125
+
1126
+ const productPath = `/activity.json/${encodeURIComponent(productId)}`;
1127
+ const { url: productUrl, pathForSigning: productPathForSigning } = buildRequestUrlAndPath(
1128
+ baseUrl,
1129
+ productPath,
1130
+ );
1131
+ const productHeaders = getHeaders({
1132
+ accessKey,
1133
+ secretKey,
1134
+ method: 'GET',
1135
+ path: productPathForSigning,
1136
+ });
1137
+
1138
+ const productResponse = await axios({
1139
+ method: 'get',
1140
+ url: productUrl,
1141
+ headers: productHeaders,
1142
+ });
1143
+
1144
+ const productData = R.pathOr({}, ['data'], productResponse);
1145
+ const leadPassengerContactInfo = normalizePassengerFields(
1146
+ R.pathOr([], ['mainContactFields'], productData),
1147
+ );
1148
+ const passengerContactInfo = normalizePassengerFields(
1149
+ R.pathOr([], ['passengerFields'], productData),
1150
+ );
1151
+ const bookingQuestions = normalizeBookingQuestions(
1152
+ R.pathOr([], ['bookingQuestions'], productData),
1153
+ );
1154
+
1155
+ return { leadPassengerContactInfo, passengerContactInfo, bookingQuestions };
1156
+ } catch (err) {
1157
+ // Best-effort enrichment only; keep static fields if remote question
1158
+ // lookup fails. Use console.error so operators still see auth/perm
1159
+ // failures, and include the HTTP status when available.
1160
+ const status = err.response?.status;
1161
+ console.error(
1162
+ `Could not fetch mandatory booking questions for product ${String(productId)} (status ${status || 'n/a'}):`,
1163
+ err.response?.data || err.message,
1164
+ );
1165
+ return { leadPassengerContactInfo: [], passengerContactInfo: [], bookingQuestions: [] };
1166
+ }
1167
+ }
1168
+
1169
+ async function toTranslatedBooking({
1170
+ bookingPayload,
1171
+ bookingTypeDefs,
1172
+ bookingQuery,
1173
+ baseUrl,
1174
+ tenant,
1175
+ }) {
1176
+ let booking = R.path(['data', 'booking'], bookingPayload)
1177
+ || R.path(['data'], bookingPayload)
1178
+ || bookingPayload?.booking
1179
+ || bookingPayload;
1180
+ const redirectUrl = R.path(['data', 'redirectRequest', 'url'], bookingPayload);
1181
+ if (typeof redirectUrl === 'string' && /^https?:\/\//i.test(redirectUrl.trim()) && !booking.publicUrl) {
1182
+ booking = { ...booking, publicUrl: redirectUrl.trim() };
1183
+ }
1184
+ const bookingUnitItems = buildUnitItemsForBooking(booking, {});
1185
+ const rootValue = { ...booking, unitItems: bookingUnitItems };
1186
+ const translatedBooking = await translateBooking({
1187
+ rootValue,
1188
+ typeDefs: bookingTypeDefs,
1189
+ query: bookingQuery,
1190
+ apiEndpoint: baseUrl,
1191
+ tenant,
1192
+ });
1193
+ if (Array.isArray(bookingUnitItems) && bookingUnitItems.length > 0) {
1194
+ translatedBooking.unitItems = bookingUnitItems;
1195
+ }
1196
+ return { booking: translatedBooking };
1197
+ }
1198
+
1199
+ function getActivityInfo({
1200
+ availabilityKey,
1201
+ jwtKey,
1202
+ unitItems,
1203
+ currency,
1204
+ product,
1205
+ }) {
1206
+ let availabilityMetadata = {};
1207
+
1208
+ if (availabilityKey) {
1209
+ try {
1210
+ availabilityMetadata = jwt.verify(availabilityKey, jwtKey);
1211
+ } catch (err) {
1212
+ console.error('Error decoding availability key:', err.message);
1213
+ throw new Error('Invalid availabilityKey');
1214
+ }
1215
+ }
1216
+
1217
+ const keyUnitItems = Array.isArray(availabilityMetadata.unitItems) ? availabilityMetadata.unitItems : [];
1218
+ const providedUnitItems = Array.isArray(unitItems) ? unitItems : [];
1219
+ const finalUnitItems = keyUnitItems.length > 0 ? keyUnitItems : providedUnitItems;
1220
+
1221
+ const bookingCurrency = currency
1222
+ || availabilityMetadata.currency
1223
+ || 'USD';
1224
+
1225
+ const rateIdFromKey = availabilityMetadata.rateId;
1226
+ const parsedRateId = rateIdFromKey && !Number.isNaN(Number(rateIdFromKey))
1227
+ ? Number(rateIdFromKey)
1228
+ : null;
1229
+
1230
+ const startTimeIdRaw = availabilityMetadata.startTimeId || availabilityMetadata.availabilityId || null;
1231
+ const parsedStartTimeId = startTimeIdRaw && !Number.isNaN(Number(startTimeIdRaw))
1232
+ ? Number(startTimeIdRaw)
1233
+ : startTimeIdRaw;
1234
+
1235
+ const activityIdFromKey = availabilityMetadata.productId;
1236
+ const bookingDate = availabilityMetadata.localDateTimeStart
1237
+ ? moment(availabilityMetadata.localDateTimeStart).format(TRAVEL_DATE_FORMAT)
1238
+ : null;
1239
+
1240
+ const activityIdRaw = activityIdFromKey || (product && (product.productId || product.id));
1241
+ const parsedActivityId = activityIdRaw != null && !Number.isNaN(Number(activityIdRaw))
1242
+ ? Number(activityIdRaw)
1243
+ : activityIdRaw;
1244
+
1245
+ return {
1246
+ finalUnitItems,
1247
+ bookingCurrency,
1248
+ parsedRateId,
1249
+ parsedStartTimeId,
1250
+ bookingDate,
1251
+ parsedActivityId,
1252
+ };
1253
+ }
1254
+
1035
1255
  class Plugin {
1036
1256
  constructor(params) {
1037
1257
  Object.entries(params).forEach(([attr, value]) => {
@@ -1042,6 +1262,13 @@ class Plugin {
1042
1262
  this.accessKey = this.accessKey || process.env.ti2_bokun_accessKey;
1043
1263
  this.secretKey = this.secretKey || process.env.ti2_bokun_secretKey;
1044
1264
 
1265
+ // Per-instance cache for the Bokun country list keyed by baseUrl.
1266
+ // Populated lazily by getCreateBookingFields and reused across calls
1267
+ // for the configured TTL (defaults to 7 days). Each cache entry is
1268
+ // either { options, expiresAt } once resolved, or { promise, expiresAt: 0 }
1269
+ // while a fetch is in flight (de-duplicates concurrent callers).
1270
+ this.countriesCacheByBaseUrl = new Map();
1271
+
1045
1272
  this.getCredentials = (token = {}) => {
1046
1273
  const accessKey = token.accessKey || this.accessKey || process.env.ti2_bokun_accessKey;
1047
1274
  const secretKey = token.secretKey || this.secretKey || process.env.ti2_bokun_secretKey;
@@ -1275,72 +1502,66 @@ class Plugin {
1275
1502
  return this.searchAvailability({ axios, token, payload, typeDefsAndQueries });
1276
1503
  }
1277
1504
 
1278
- async searchPickupPoints({
1279
- axios,
1280
- token,
1281
- payload: { productId },
1282
- typeDefsAndQueries: { pickupTypeDefs, pickupQuery },
1283
- }) {
1284
- const { endpoint, testMode } = token || {};
1285
- const { accessKey, secretKey } = this.getCredentials(token);
1286
- const baseUrl = testMode ? 'https://api.bokuntest.com' : (endpoint || this.endpoint || 'https://api.bokun.io');
1287
- const path = `/activity.json/${productId}/pickup-places`;
1288
- const { url, pathForSigning } = buildRequestUrlAndPath(baseUrl, path);
1289
- const headers = getHeaders({
1290
- accessKey,
1291
- secretKey,
1292
- method: 'GET',
1293
- path: pathForSigning,
1294
- });
1295
-
1296
- try {
1297
- const response = await axios({
1298
- method: 'get',
1299
- url,
1300
- headers,
1301
- });
1302
-
1303
- const pickupPoints = R.pathOr([], ['data'], response);
1304
-
1305
- const translatedPoints = await Promise.map(pickupPoints, async point => (
1306
- translatePickupPoint({
1307
- rootValue: point,
1308
- typeDefs: pickupTypeDefs,
1309
- query: pickupQuery,
1310
- })
1311
- ), { concurrency: CONCURRENCY });
1505
+ async getCreateBookingFields({ axios, token, payload = {}, query = {} } = {}) {
1506
+ const requestData = Object.keys(query).length > 0 ? query : payload;
1507
+ const { productId } = requestData;
1508
+
1509
+ // Fetch the per-product booking questions and the global country list in
1510
+ // parallel. The country list is cached for COUNTRIES_CACHE_TTL_MS so
1511
+ // subsequent calls reuse the result and only the question fetch hits the
1512
+ // network. We only allow a country fetch when we'd already be calling
1513
+ // Bokun for questions otherwise we return the static list and avoid
1514
+ // adding HTTP traffic to caller-supplied flows.
1515
+ const [productQuestionPayload, countryOptions] = await Promise.all([
1516
+ fetchMandatoryBookingQuestionsForProduct(this, { axios, token, productId }),
1517
+ fetchCountriesList(this, {
1518
+ axios,
1519
+ token,
1520
+ useNetwork: true,
1521
+ }),
1522
+ ]);
1523
+ const { leadPassengerContactInfo, passengerContactInfo, bookingQuestions } = productQuestionPayload;
1524
+ const expandedLeadPassengerContactInfo = expandRequiredCustomerQuestionIds(leadPassengerContactInfo);
1525
+ const expandedPassengerContactInfo = expandRequiredCustomerQuestionIds(passengerContactInfo);
1526
+ const fieldsWithPassengerFlags = applyPassengerFieldsToUiFields(
1527
+ expandedLeadPassengerContactInfo,
1528
+ expandedPassengerContactInfo,
1529
+ );
1530
+ const fields = decorateCountryFields(fieldsWithPassengerFlags, countryOptions);
1531
+ const customFields = buildCustomFieldsFromQuestions([
1532
+ bookingQuestions,
1533
+ ]);
1312
1534
 
1313
- return ({ pickupPoints: translatedPoints });
1314
- } catch (err) {
1315
- console.error('Error fetching pickup points:', err.response?.data || err.message);
1316
- throw err;
1317
- }
1318
- }
1535
+ const excludedCustomerFieldIds = new Set(
1536
+ (Array.isArray(DEFAULT_EXCLUDED_CUSTOMER_QUESTION_IDS) ? DEFAULT_EXCLUDED_CUSTOMER_QUESTION_IDS : [])
1537
+ .map(id => String(id || '').trim())
1538
+ .filter(Boolean),
1539
+ );
1319
1540
 
1320
- // eslint-disable-next-line class-methods-use-this
1321
- async getCreateBookingFields() {
1322
- const fields = [
1323
- { id: 'firstName', title: 'First name', type: 'short', required: true },
1324
- { id: 'lastName', title: 'Last name', type: 'short', required: true },
1325
- { id: 'emailAddress', title: 'Email', type: 'short', required: true },
1326
- { id: 'phoneNumber', title: 'Phone', type: 'short', required: false },
1327
- { id: 'country', title: 'Country', type: 'short', required: false },
1328
- { id: 'postalCode', title: 'Postal code', type: 'short', required: false },
1329
- ];
1330
-
1331
- // Optional per-product custom booking questions can be added later.
1332
- const customFields = [];
1333
- return { fields, customFields };
1334
- }
1541
+ const additionalCustomerFields = fields
1542
+ .filter(field => field && field.visiblePerBooking)
1543
+ .filter(field => !excludedCustomerFieldIds.has(String(field.id || '').trim()))
1544
+ .map(field => ({ ...field, required: Boolean(field.requiredPerBooking), isCustomerField: true }));
1335
1545
 
1336
- async getCreateItineraryFields(args = {}) {
1337
- return this.getCreateBookingFields(args);
1546
+ return { fields, customFields, additionalCustomerFields };
1338
1547
  }
1339
1548
 
1340
1549
  /**
1341
1550
  * Create a booking using Bokun checkout endpoints.
1342
- * - Uses /checkout.json/submit with directBooking
1343
- * - Then /checkout.json/confirm-reserved/{code} to confirm reservation
1551
+ * - POST /checkout.json/submit with directBooking
1552
+ * - POST /checkout.json/confirm-reserved/{code}
1553
+ *
1554
+ * @param {Object} args
1555
+ * @param {Object} args.axios - axios instance for the outbound calls
1556
+ * @param {Object} args.token - vendor token (provides credentials/endpoint)
1557
+ * @param {Object} args.payload - booking payload from the UI
1558
+ * @param {Object} args.typeDefsAndQueries - GraphQL typeDefs/query used to translate the response
1559
+ * @returns {Promise<{ booking: Object|null, error?: string }>} On success
1560
+ * `{ booking }`. On checkout failures, missing confirmation code, or
1561
+ * options preflight validation failure, `{ booking: null, error }`.
1562
+ * @throws {Error} When `holder` contact validation fails (invalid/missing
1563
+ * email, etc.) or when `availabilityKey` cannot be decoded — same contract
1564
+ * as earlier versions that threw on invalid contact email.
1344
1565
  */
1345
1566
  async createBooking({
1346
1567
  axios,
@@ -1355,53 +1576,80 @@ class Plugin {
1355
1576
  integrationIsDirectBooking = false,
1356
1577
  product,
1357
1578
  unitItems,
1579
+ participants = [],
1358
1580
  currency,
1581
+ mainContactDetails: providedMainContactDetails = [],
1582
+ activityAnswers = [],
1583
+ customFieldValues = [],
1584
+ dateFormat: customFieldDateFormat,
1585
+ sendNotificationToMainContact = true,
1359
1586
  } = payload;
1360
-
1361
1587
  const { endpoint, testMode } = token || {};
1362
1588
  const { accessKey, secretKey } = this.getCredentials(token);
1363
1589
  const jwtKey = this.getJwtKey(token);
1364
1590
  assert(jwtKey, 'JWT secret should be set for availability keys');
1365
1591
 
1366
1592
  const baseUrl = testMode ? 'https://api.bokuntest.com' : (endpoint || this.endpoint || 'https://api.bokun.io');
1367
- let availabilityMetadata = {};
1368
-
1369
- if (availabilityKey) {
1370
- try {
1371
- availabilityMetadata = jwt.verify(availabilityKey, jwtKey);
1372
- } catch (err) {
1373
- console.error('Error decoding availability key:', err.message);
1374
- throw new Error('Invalid availabilityKey');
1375
- }
1376
- }
1377
-
1378
- const keyUnitItems = Array.isArray(availabilityMetadata.unitItems) ? availabilityMetadata.unitItems : [];
1379
- const providedUnitItems = Array.isArray(unitItems) ? unitItems : [];
1380
- const finalUnitItems = keyUnitItems.length > 0 ? keyUnitItems : providedUnitItems;
1381
-
1382
- const bookingCurrency = currency
1383
- || availabilityMetadata.currency
1384
- || 'USD';
1385
-
1386
- const rateIdFromKey = availabilityMetadata.rateId;
1387
- const parsedRateId = rateIdFromKey && !Number.isNaN(Number(rateIdFromKey))
1388
- ? Number(rateIdFromKey)
1389
- : null;
1390
-
1391
- const startTimeIdRaw = availabilityMetadata.startTimeId || availabilityMetadata.availabilityId || null;
1392
- const parsedStartTimeId = startTimeIdRaw && !Number.isNaN(Number(startTimeIdRaw))
1393
- ? Number(startTimeIdRaw)
1394
- : startTimeIdRaw;
1395
-
1396
- const activityIdFromKey = availabilityMetadata.productId;
1397
- const bookingDate = availabilityMetadata.localDateTimeStart
1398
- ? moment(availabilityMetadata.localDateTimeStart).format('YYYY-MM-DD')
1399
- : null;
1593
+ const {
1594
+ finalUnitItems,
1595
+ bookingCurrency,
1596
+ parsedRateId,
1597
+ parsedStartTimeId,
1598
+ bookingDate,
1599
+ parsedActivityId,
1600
+ } = getActivityInfo({
1601
+ availabilityKey,
1602
+ jwtKey,
1603
+ unitItems,
1604
+ currency,
1605
+ product,
1606
+ });
1400
1607
 
1401
- const activityIdRaw = activityIdFromKey || (product && (product.productId || product.id));
1402
- const parsedActivityId = activityIdRaw != null && !Number.isNaN(Number(activityIdRaw))
1403
- ? Number(activityIdRaw)
1404
- : activityIdRaw;
1608
+ // Validate basic contact details first (no network). If the only failure
1609
+ // is country resolution (or phone format dependent on country), retry once
1610
+ // with the live country list so newly-added Bokun countries are accepted.
1611
+ let contactInfo = validateContactInfo(holder);
1612
+ if (shouldRetryContactValidationWithDynamicCountries(contactInfo, holder)) {
1613
+ const countryOptions = await fetchCountriesList(this, { axios, token });
1614
+ contactInfo = validateContactInfo(holder, { countryOptions });
1615
+ }
1616
+ if (!contactInfo.ok) {
1617
+ const contactErrors = getContactValidationErrors(contactInfo);
1618
+ const contactErrorMessage = contactErrors.join(', ');
1619
+ console.error('Error creating booking: ', contactErrorMessage);
1620
+ throw new Error(contactErrorMessage);
1621
+ }
1622
+ const defaultMainContactDetails = getMainContactDetails(contactInfo.contact);
1623
+ const providedMainContactDetailsNormalized = normalizeQuestionAnswerList(providedMainContactDetails);
1624
+ const mergedMainContactDetailsByQuestionId = {};
1625
+ defaultMainContactDetails.forEach(answer => {
1626
+ const normalizedAnswer = normalizeMainContactAnswer(answer);
1627
+ if (!normalizedAnswer?.questionId) return;
1628
+ mergedMainContactDetailsByQuestionId[normalizedAnswer.questionId] = normalizedAnswer;
1629
+ });
1630
+ providedMainContactDetailsNormalized.forEach(answer => {
1631
+ const normalizedAnswer = normalizeMainContactAnswer(answer);
1632
+ if (!normalizedAnswer?.questionId) return;
1633
+ mergedMainContactDetailsByQuestionId[normalizedAnswer.questionId] = normalizedAnswer;
1634
+ });
1635
+ const normalizedActivityAnswers = normalizeQuestionAnswerList(activityAnswers);
1636
+ const {
1637
+ bookingAnswers,
1638
+ passengerAnswersByIndex,
1639
+ customerAnswers,
1640
+ } = mapCustomFieldValuesToAnswers(customFieldValues, {
1641
+ normalizeFieldValue: ({ field, value }) => normalizeCustomFieldValueForBooking({
1642
+ field,
1643
+ value,
1644
+ inputDateFormat: customFieldDateFormat,
1645
+ }),
1646
+ });
1647
+ customerAnswers.forEach(answer => {
1648
+ const normalizedAnswer = normalizeMainContactAnswer(answer);
1649
+ if (!normalizedAnswer?.questionId) return;
1650
+ mergedMainContactDetailsByQuestionId[normalizedAnswer.questionId] = normalizedAnswer;
1651
+ });
1652
+ const mainContactDetails = Object.values(mergedMainContactDetailsByQuestionId);
1405
1653
 
1406
1654
  const source = DIRECTBOOKING_SOURCE;
1407
1655
  const checkoutOption = integrationIsDirectBooking
@@ -1409,71 +1657,88 @@ class Plugin {
1409
1657
  : CHECKOUT_OPTION_AGENT_RESERVATION;
1410
1658
  const paymentMethod = PAYMENT_METHOD;
1411
1659
 
1412
- const toTranslatedBooking = async bookingPayload => {
1413
- let booking = R.path(['data', 'booking'], bookingPayload)
1414
- || R.path(['data'], bookingPayload)
1415
- || bookingPayload?.booking
1416
- || bookingPayload;
1417
- const redirectUrl = R.path(['data', 'redirectRequest', 'url'], bookingPayload);
1418
- if (typeof redirectUrl === 'string' && /^https?:\/\//i.test(redirectUrl.trim()) && !booking.publicUrl) {
1419
- booking = { ...booking, publicUrl: redirectUrl.trim() };
1420
- }
1421
- const bookingUnitItems = buildUnitItemsForBooking(booking, {});
1422
- const rootValue = { ...booking, unitItems: bookingUnitItems };
1423
- const translatedBooking = await translateBooking({
1424
- rootValue,
1425
- typeDefs: bookingTypeDefs,
1426
- query: bookingQuery,
1427
- apiEndpoint: baseUrl,
1428
- tenant: token.tenant,
1429
- });
1430
- if (Array.isArray(bookingUnitItems) && bookingUnitItems.length > 0) {
1431
- translatedBooking.unitItems = bookingUnitItems;
1432
- }
1433
- return { booking: translatedBooking };
1434
- };
1435
-
1436
1660
  // Booking flow via checkout:
1437
1661
  // 1) POST /checkout.json/submit with directBooking
1438
1662
  // 2) POST /checkout.json/confirm-reserved/{code}
1439
1663
 
1440
- const passengersArray = (Array.isArray(finalUnitItems) ? finalUnitItems : []).map(item => ({
1441
- pricingCategoryId: item.unitId != null ? Number(item.unitId) : undefined,
1442
- ...(item.quantity ? { quantity: item.quantity } : {}),
1443
- })).filter(item => item.pricingCategoryId != null);
1664
+ // Expand unit items so every passenger gets its own entry (quantity: 1).
1665
+ // A unit item with quantity N becomes N individual entries so that
1666
+ // participants[i].fields maps cleanly onto the i-th passenger.
1667
+ const expandedUnitItems = [];
1668
+ (Array.isArray(finalUnitItems) ? finalUnitItems : []).forEach(item => {
1669
+ const qty = Number(item.quantity) || 1;
1670
+ let q = 0;
1671
+ while (q < qty) {
1672
+ expandedUnitItems.push({ ...item, quantity: 1 });
1673
+ q += 1;
1674
+ }
1675
+ });
1444
1676
 
1677
+ const passengersArray = expandedUnitItems.map((item, passengerIndex) => {
1678
+ // Answers from the JWT availability key (booking-question answers per passenger)
1679
+ const itemAnswers = normalizeQuestionAnswerList(item.answers)
1680
+ .map(a => ({ ...a, passengerIndex }));
1681
+
1682
+ // Per-passenger answers from participants[i].fields (UI form).
1683
+ // Each entry: { field: { id, type, isPerUnitItem, ... }, value }
1684
+ const participant = Array.isArray(participants) ? participants[passengerIndex] : undefined;
1685
+ // customFieldValues → per-passenger booking-question answers (sent as answers with passengerIndex)
1686
+ const participantCustomFieldValues = participant?.customFieldValues || [];
1687
+ // fields → per-passenger contact info (handled separately, explained below)
1688
+ const participantContactFields = participant?.fields || [];
1689
+
1690
+ console.log('SACHIN participantCustomFieldValues: ', JSON.stringify(participantCustomFieldValues, null, 2));
1691
+ console.log('SACHIN participantContactFields: ', JSON.stringify(participantContactFields, null, 2));
1692
+ const participantAnswers = participantCustomFieldValues
1693
+ .map(entry => {
1694
+ const questionId = entry?.field?.id || entry?.field?.questionId;
1695
+ if (!questionId) return null;
1696
+ const raw = entry.value;
1697
+ const values = (Array.isArray(raw) ? raw : [raw])
1698
+ .filter(v => v != null && v !== '');
1699
+ if (values.length === 0) return null;
1700
+ return { questionId: String(questionId), values, passengerIndex };
1701
+ })
1702
+ .filter(Boolean);
1703
+
1704
+ console.log('SACHIN participantAnswers: ', JSON.stringify(participantAnswers, null, 2));
1705
+ const passengerDetails = participantContactFields
1706
+ .map(entry => {
1707
+ const questionId = entry?.field?.id || entry?.field?.questionId;
1708
+ if (!questionId) return null;
1709
+ const raw = entry.value;
1710
+ const values = (Array.isArray(raw) ? raw : [raw])
1711
+ .map(v => normalizePassengerDetailValue({ questionId, value: v }))
1712
+ .filter(v => v != null && v !== '');
1713
+ if (values.length === 0) return null;
1714
+ return { questionId: String(questionId), values, passengerIndex };
1715
+ })
1716
+ .filter(Boolean);
1717
+
1718
+ console.log('SACHIN passengerDetails: ', JSON.stringify(passengerDetails, null, 2));
1719
+ // Indexed customFieldValues (field|N suffix) also scoped to this passenger
1720
+ const indexedAnswers = (passengerAnswersByIndex[passengerIndex] || [])
1721
+ .map(a => ({ ...a, passengerIndex }));
1722
+
1723
+ const allAnswers = [...itemAnswers, ...participantAnswers, ...indexedAnswers];
1724
+
1725
+ return {
1726
+ pricingCategoryId: item.unitId != null ? Number(item.unitId) : undefined,
1727
+ quantity: 1,
1728
+ ...(allAnswers.length > 0 ? { answers: allAnswers } : {}),
1729
+ ...(passengerDetails.length > 0 ? { passengerDetails } : {}),
1730
+ };
1731
+ }).filter(item => item.pricingCategoryId != null);
1445
1732
  const activityBookingRequest = {
1446
1733
  activityId: parsedActivityId,
1447
1734
  ...(parsedRateId != null ? { rateId: parsedRateId } : {}),
1448
1735
  ...(bookingDate ? { date: bookingDate } : {}),
1449
1736
  ...(parsedStartTimeId ? { startTimeId: parsedStartTimeId } : {}),
1737
+ ...((normalizedActivityAnswers.length > 0 || bookingAnswers.length > 0)
1738
+ ? { answers: [...normalizedActivityAnswers, ...bookingAnswers] }
1739
+ : {}),
1450
1740
  passengers: passengersArray,
1451
1741
  };
1452
-
1453
- const firstNameValue = holder?.firstName || (holder?.name ? holder.name.split(' ')[0] : '') || '';
1454
- const rawLastName = holder?.surname || (holder?.name ? holder.name.split(' ').slice(1).join(' ') : '') || '';
1455
- const lastNameValue = rawLastName || firstNameValue || 'Guest';
1456
- const activityEmailCheck = validateBookingEmail(holder?.email || holder?.emailAddress);
1457
- if (!activityEmailCheck.ok) {
1458
- throw new Error(activityEmailCheck.error);
1459
- }
1460
- const emailValue = activityEmailCheck.email;
1461
-
1462
- const mainContactDetails = [
1463
- {
1464
- questionId: 'firstName',
1465
- values: [firstNameValue],
1466
- },
1467
- {
1468
- questionId: 'lastName',
1469
- values: [lastNameValue],
1470
- },
1471
- {
1472
- questionId: 'email',
1473
- values: [emailValue],
1474
- },
1475
- ];
1476
-
1477
1742
  const directBooking = {
1478
1743
  mainContactDetails,
1479
1744
  activityBookings: [activityBookingRequest],
@@ -1485,11 +1750,53 @@ class Plugin {
1485
1750
  paymentMethod,
1486
1751
  source,
1487
1752
  directBooking,
1488
- sendNotificationToMainContact: true,
1753
+ sendNotificationToMainContact,
1489
1754
  showPricesInNotification: true,
1490
1755
  currency: bookingCurrency,
1491
1756
  };
1492
1757
 
1758
+ try {
1759
+ const optionsPath = `/checkout.json/options/booking-request?currency=${
1760
+ encodeURIComponent(bookingCurrency)
1761
+ }&lang=EN`;
1762
+ const {
1763
+ url: optionsUrl,
1764
+ pathForSigning: optionsPathForSigning,
1765
+ } = buildRequestUrlAndPath(baseUrl, optionsPath);
1766
+ const optionsHeaders = getHeaders({
1767
+ accessKey,
1768
+ secretKey,
1769
+ method: 'POST',
1770
+ path: optionsPathForSigning,
1771
+ });
1772
+ const optionsResponse = await axios({
1773
+ method: 'post',
1774
+ url: optionsUrl,
1775
+ headers: optionsHeaders,
1776
+ data: directBooking,
1777
+ });
1778
+ const optionsData = R.pathOr({}, ['data'], optionsResponse);
1779
+ const requiredQuestions = extractRequiredQuestions(optionsData);
1780
+
1781
+ const requiredFieldError = validateRequiredContactFields({
1782
+ requiredQuestions,
1783
+ mainContactDetails: directBooking.mainContactDetails,
1784
+ sendNotificationToMainContact,
1785
+ });
1786
+ if (requiredFieldError) {
1787
+ console.error('Error creating booking via checkout:', requiredFieldError);
1788
+ return { booking: null, error: requiredFieldError };
1789
+ }
1790
+ } catch (optionsErr) {
1791
+ // Best-effort: don't block booking creation if Bokun's options endpoint
1792
+ // is unavailable. Surface as a warning so it doesn't trip error alarms.
1793
+ console.warn(
1794
+ 'Could not fetch required booking questions (status %s): %j',
1795
+ optionsErr.response?.status || 'n/a',
1796
+ optionsErr.response?.data || optionsErr.message,
1797
+ );
1798
+ }
1799
+
1493
1800
  const submitPath = '/checkout.json/submit?lang=EN';
1494
1801
  const { url: submitUrl, pathForSigning: submitPathForSigning } = buildRequestUrlAndPath(baseUrl, submitPath);
1495
1802
  const submitHeaders = getHeaders({
@@ -1538,7 +1845,7 @@ class Plugin {
1538
1845
  const bookingConfirmationBody = {
1539
1846
  externalBookingReference: externalBookingReference || null,
1540
1847
  showPricesInNotification: true,
1541
- sendNotificationToMainContact: true,
1848
+ sendNotificationToMainContact,
1542
1849
  amount: amountForConfirmation,
1543
1850
  currency: bookingCurrency,
1544
1851
  };
@@ -1550,56 +1857,24 @@ class Plugin {
1550
1857
  data: bookingConfirmationBody,
1551
1858
  });
1552
1859
 
1553
- return await toTranslatedBooking(confirmResponse);
1860
+ return await toTranslatedBooking({
1861
+ bookingPayload: confirmResponse,
1862
+ bookingTypeDefs,
1863
+ bookingQuery,
1864
+ baseUrl,
1865
+ tenant: token.tenant,
1866
+ });
1554
1867
  } catch (err) {
1555
1868
  const details = err.response?.data || err.message;
1869
+ const errorMessage = extractBokunErrorMessage(err);
1556
1870
  console.error('Error creating booking via checkout:', details);
1557
- return { booking: null, error: typeof details === 'string' ? details : JSON.stringify(details) };
1871
+ return {
1872
+ booking: null,
1873
+ error: errorMessage || (typeof details === 'string' ? details : JSON.stringify(details)),
1874
+ };
1558
1875
  }
1559
1876
  }
1560
1877
 
1561
- // /**
1562
- // * Get checkout options for a booking request.
1563
- // * Wraps POST /checkout.json/options/booking-request
1564
- // * Schema: body is BookingRequest, response is Checkout.
1565
- // */
1566
- // async checkoutOptionsForBookingRequest({
1567
- // axios,
1568
- // token,
1569
- // payload = {},
1570
- // }) {
1571
- // const { endpoint, testMode } = token || {};
1572
- // const { accessKey, secretKey } = this.getCredentials(token);
1573
- // const baseUrl = testMode ? 'https://api.bokuntest.com' : (endpoint || this.endpoint || 'https://api.bokun.io');
1574
-
1575
- // const { bookingRequest } = payload;
1576
- // assert(bookingRequest, 'bookingRequest is required for checkoutOptionsForBookingRequest');
1577
-
1578
- // const path = '/checkout.json/options/booking-request';
1579
- // const { url, pathForSigning } = buildRequestUrlAndPath(baseUrl, path);
1580
- // const headers = getHeaders({
1581
- // accessKey,
1582
- // secretKey,
1583
- // method: 'POST',
1584
- // path: pathForSigning,
1585
- // });
1586
-
1587
- // try {
1588
- // const response = await axios({
1589
- // method: 'post',
1590
- // url,
1591
- // headers,
1592
- // data: bookingRequest,
1593
- // });
1594
- // const checkout = R.pathOr({}, ['data'], response);
1595
- // return { checkout };
1596
- // } catch (err) {
1597
- // const details = err.response?.data || err.message;
1598
- // console.error('Error fetching checkout options for booking request:', details);
1599
- // return { checkout: null, error: typeof details === 'string' ? details : JSON.stringify(details) };
1600
- // }
1601
- // }
1602
-
1603
1878
  async cancelBooking({
1604
1879
  axios,
1605
1880
  token,