ti2-tourplan 1.0.138 → 1.0.140

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.
@@ -4151,6 +4151,7 @@ Object {
4151
4151
  "description": null,
4152
4152
  "options": Array [
4153
4153
  Object {
4154
+ "chargeUnit": "day",
4154
4155
  "city": "London",
4155
4156
  "comment": "FIT- V- Class (1-5 Pax)",
4156
4157
  "country": "United Kingdom",
package/index.test.js CHANGED
@@ -36,6 +36,11 @@ jest.mock('axios');
36
36
 
37
37
  // Default callTourplan mock: resolve HostConnect replies from __fixtures__.
38
38
  const defaultCallTourplanImplementation = async ({ model, endpoint }) => {
39
+ // Supplier notes are optional. Product search must not depend on a fixture
40
+ // for the unfiltered SupplierInfo request.
41
+ if (model.SupplierInfoRequest) {
42
+ return { SupplierInfoReply: { Suppliers: { Supplier: [] } } };
43
+ }
39
44
  // Create a mock request object similar to what axios would receive
40
45
  const requestType = Object.keys(model)[0]; // Gets 'OptionInfoRequest'
41
46
 
@@ -339,6 +344,100 @@ describe('search tests', () => {
339
344
  expect(request.Remarks).toBe(`Passenger Notes: ${'A'.repeat(43)}`);
340
345
  expect(request.Remarks).toHaveLength(60);
341
346
  });
347
+
348
+ it.each([
349
+ ['an exact boundary reference', { reference: 'A'.repeat(60) }, 'A'.repeat(60)],
350
+ ['an overlong reference', { reference: 'B'.repeat(61) }, 'B'.repeat(60)],
351
+ ['a long itinerary description', {
352
+ reference: 'A detailed anniversary itinerary with transfers, hotels, touring, and extra arrangements',
353
+ }, 'A detailed anniversary itinerary with transfers, hotels, tou'],
354
+ ['the fallback reference', { reference: ' FALLBACK-REFERENCE ' }, 'FALLBACK-REFERENCE'],
355
+ ['the preferred agentRef', {
356
+ agentRef: ' AGENT-PICKED ',
357
+ reference: 'REFERENCE-IGNORED',
358
+ }, 'AGENT-PICKED'],
359
+ ['a value that expands during sanitization', { agentRef: 'Ä'.repeat(40) }, 'Ae'.repeat(30)],
360
+ ['invalid XML characters', { agentRef: ' REF\u0000WITH\u0001CONTROL ' }, 'REFWITHCONTROL'],
361
+ ])('normalizes AgentRef from %s without changing the payload', async (
362
+ description,
363
+ sourceFields,
364
+ expected,
365
+ ) => {
366
+ mockCallTourplan.mockImplementationOnce(async () => ({
367
+ AddServiceReply: { BookingId: '12345', Ref: 'TESTREF', ServiceLineId: '10' },
368
+ }));
369
+ const payload = {
370
+ quoteName: `Agent reference test: ${description}`,
371
+ optionId: 'ABC123',
372
+ startDate: '2026-07-03',
373
+ paxConfigs: [{ roomType: 'Double', adults: 2 }],
374
+ notes: '',
375
+ ...sourceFields,
376
+ };
377
+ const originalPayload = { ...payload };
378
+
379
+ await app.addServiceToItinerary({ axios, token, payload });
380
+
381
+ const agentReference = mockCallTourplan.mock.calls[0][0].model.AddServiceRequest.AgentRef;
382
+ expect(agentReference).toBe(expected);
383
+ expect(Array.from(agentReference).length).toBeLessThanOrEqual(60);
384
+ expect(payload).toEqual(originalPayload);
385
+
386
+ const requestXml = js2xmlparser.parse(
387
+ 'Request',
388
+ mockCallTourplan.mock.calls[0][0].model,
389
+ hostConnectXmlOptions,
390
+ );
391
+ expect(requestXml.indexOf('<SCUqty>')).toBeLessThan(requestXml.indexOf('<AgentRef>'));
392
+ expect(requestXml.indexOf('<AgentRef>')).toBeLessThan(requestXml.indexOf('<RoomConfigs>'));
393
+ });
394
+
395
+ it('omits AgentRef when the selected source sanitizes to empty', async () => {
396
+ mockCallTourplan.mockImplementationOnce(async () => ({
397
+ AddServiceReply: { BookingId: '12345', Ref: 'TESTREF', ServiceLineId: '10' },
398
+ }));
399
+
400
+ await app.addServiceToItinerary({
401
+ axios,
402
+ token,
403
+ payload: {
404
+ quoteName: 'Empty agent reference',
405
+ optionId: 'ABC123',
406
+ startDate: '2026-07-03',
407
+ agentRef: ' \u0001 ',
408
+ reference: 'REFERENCE-IGNORED',
409
+ paxConfigs: [{ roomType: 'Double', adults: 2 }],
410
+ notes: '',
411
+ },
412
+ });
413
+
414
+ expect(mockCallTourplan.mock.calls[0][0].model.AddServiceRequest.AgentRef).toBeUndefined();
415
+ });
416
+
417
+ it('normalizes a direct-line AgentRef override without changing XML element order', async () => {
418
+ mockCallTourplan.mockImplementationOnce(async () => ({
419
+ AddServiceReply: { BookingId: '12345', Ref: 'TESTREF', ServiceLineId: '10' },
420
+ }));
421
+
422
+ await app.addServiceToItinerary({
423
+ axios,
424
+ token,
425
+ payload: {
426
+ quoteName: 'Direct override agent reference',
427
+ optionId: 'ABC123',
428
+ startDate: '2026-07-03',
429
+ reference: 'REFERENCE-IGNORED',
430
+ paxConfigs: [{ roomType: 'Double', adults: 2 }],
431
+ notes: '',
432
+ directLinePayload: { AgentRef: 'D'.repeat(61) },
433
+ },
434
+ });
435
+
436
+ const model = mockCallTourplan.mock.calls[0][0].model;
437
+ expect(model.AddServiceRequest.AgentRef).toBe('D'.repeat(60));
438
+ const requestXml = js2xmlparser.parse('Request', model, hostConnectXmlOptions);
439
+ expect(requestXml.indexOf('<AgentRef>')).toBeLessThan(requestXml.indexOf('<RoomConfigs>'));
440
+ });
342
441
  });
343
442
 
344
443
  describe('cancelBooking', () => {
@@ -932,6 +1031,91 @@ describe('search tests', () => {
932
1031
  expect(retVal).toMatchSnapshot();
933
1032
  });
934
1033
 
1034
+ it('keeps class, charge unit, Other, infant cap, and supplier note on the stock schema', async () => {
1035
+ mockCallTourplan.mockImplementation(async ({ model }) => {
1036
+ if (model.AgentInfoRequest) {
1037
+ return { AgentInfoReply: { Currency: 'GBP' } };
1038
+ }
1039
+ if (model.GetLocationsRequest) {
1040
+ return { GetLocationsReply: { Locations: { Location: [] } } };
1041
+ }
1042
+ if (model.GetServicesRequest) {
1043
+ return { GetServicesReply: { TPLServices: { TPLService: [] } } };
1044
+ }
1045
+ if (model.GetSystemSettingsRequest) {
1046
+ return { GetSystemSettingsReply: { Countries: { Country: [] } } };
1047
+ }
1048
+ if (model.SupplierInfoRequest) {
1049
+ return {
1050
+ SupplierInfoReply: {
1051
+ Suppliers: {
1052
+ Supplier: {
1053
+ SupplierId: '7318',
1054
+ Name: 'Example Hotel',
1055
+ SupplierNotes: {
1056
+ SupplierNote: { NoteText: ' Family owned hotel ' },
1057
+ },
1058
+ },
1059
+ },
1060
+ },
1061
+ };
1062
+ }
1063
+ if (model.OptionInfoRequest) {
1064
+ return {
1065
+ OptionInfoReply: {
1066
+ Option: {
1067
+ Opt: 'LONHOEXAMPLESTD',
1068
+ OptGeneral: {
1069
+ SupplierId: '7318',
1070
+ SupplierName: 'Example Hotel',
1071
+ Description: 'Standard Room',
1072
+ Class: '3*',
1073
+ ClassDescription: '3 Star',
1074
+ SCU: 'Night',
1075
+ SType: 'Y',
1076
+ ButtonName: 'Accommodation',
1077
+ Single_Avail: 'Y',
1078
+ Single_Max: '0',
1079
+ Single_Ad_Max: '2',
1080
+ Single_Max_With_Infants: '1',
1081
+ Other_Avail: 'Y',
1082
+ Other_Max: '4',
1083
+ Other_Ad_Max: '3',
1084
+ Other_Max_With_Infants: '5',
1085
+ },
1086
+ },
1087
+ },
1088
+ };
1089
+ }
1090
+ return {};
1091
+ });
1092
+
1093
+ const retVal = await app.searchProductsForItinerary({
1094
+ axios,
1095
+ token,
1096
+ typeDefsAndQueries,
1097
+ payload: { optionId: 'LONHOEXAMPLESTD' },
1098
+ });
1099
+ const option = retVal.products[0].options[0];
1100
+ expect(retVal.products[0].description).toBe('Family owned hotel');
1101
+ expect(option.optionClass).toBe('3 Star');
1102
+ expect(option.chargeUnit).toBe('night');
1103
+ expect(option.restrictions.Single.maxPax).toBe(0);
1104
+ expect(option.restrictions.Single.maxPaxWithInfants).toBe(1);
1105
+ expect(option.restrictions.Other).toEqual({
1106
+ allowed: true,
1107
+ maxPax: 4,
1108
+ maxAdults: 3,
1109
+ maxPaxWithInfants: 5,
1110
+ });
1111
+ expect(option.units.map(unit => unit.unitId)).toEqual([
1112
+ 'Single', 'Twin', 'Double', 'Triple', 'Quad', 'Other',
1113
+ ]);
1114
+ const single = option.units.find(unit => unit.unitId === 'Single');
1115
+ expect(single.restrictions.maxPax).toBe(0);
1116
+ expect(single.restrictions.maxPaxWithInfants).toBe(1);
1117
+ });
1118
+
935
1119
  describe('searchProductsForItinerary pickupPoints', () => {
936
1120
  const singlePickupPoint = {
937
1121
  Point_ID: '504',
@@ -956,6 +1140,9 @@ describe('search tests', () => {
956
1140
  if (model.GetSystemSettingsRequest) {
957
1141
  return { GetSystemSettingsReply: { Countries: { Country: [] } } };
958
1142
  }
1143
+ if (model.SupplierInfoRequest) {
1144
+ return { SupplierInfoReply: { Suppliers: { Supplier: [] } } };
1145
+ }
959
1146
  if (model.OptionInfoRequest) {
960
1147
  return {
961
1148
  OptionInfoReply: {
@@ -1209,6 +1396,9 @@ describe('search tests', () => {
1209
1396
  const mockFullCatalog = ({ throwOnOpt } = {}) => {
1210
1397
  mockCallTourplan.mockImplementation(async ({ model }) => {
1211
1398
  if (model.GetServicesRequest) return getServicesReply;
1399
+ if (model.SupplierInfoRequest) {
1400
+ return { SupplierInfoReply: { Suppliers: { Supplier: [] } } };
1401
+ }
1212
1402
  const opt = model.OptionInfoRequest && model.OptionInfoRequest.Opt;
1213
1403
  if (throwOnOpt && throwOnOpt.includes(opt)) {
1214
1404
  throw new Error(`${opt} OptionInfo should have been skipped`);
@@ -1278,6 +1468,9 @@ describe('search tests', () => {
1278
1468
  if (model.GetSystemSettingsRequest) {
1279
1469
  return { GetSystemSettingsReply: { Countries: { Country: [] } } };
1280
1470
  }
1471
+ if (model.SupplierInfoRequest) {
1472
+ return { SupplierInfoReply: { Suppliers: { Supplier: [] } } };
1473
+ }
1281
1474
  if (model.GetServicesRequest) return getServicesReply;
1282
1475
  const opt = model.OptionInfoRequest && model.OptionInfoRequest.Opt;
1283
1476
  if (optionsByOpt[opt]) {
@@ -1523,6 +1716,9 @@ describe('search tests', () => {
1523
1716
  },
1524
1717
  };
1525
1718
  }
1719
+ if (model.SupplierInfoRequest) {
1720
+ return { SupplierInfoReply: { Suppliers: { Supplier: [] } } };
1721
+ }
1526
1722
  const opt = model.OptionInfoRequest && model.OptionInfoRequest.Opt;
1527
1723
  if (opt === '???SM????????????') {
1528
1724
  return {
@@ -1605,6 +1801,9 @@ describe('search tests', () => {
1605
1801
  },
1606
1802
  };
1607
1803
  }
1804
+ if (model.SupplierInfoRequest) {
1805
+ return { SupplierInfoReply: { Suppliers: { Supplier: [] } } };
1806
+ }
1608
1807
  if (model.OptionInfoRequest) {
1609
1808
  return {
1610
1809
  OptionInfoReply: {
@@ -2,6 +2,7 @@ const R = require('ramda');
2
2
  const {
3
3
  getRoomConfigs,
4
4
  escapeInvalidXmlChars,
5
+ normalizeAgentReference,
5
6
  CUSTOM_RATE_ID_NAME,
6
7
  CUSTOM_NO_RATE_NAME,
7
8
  hostConnectXmlOptions,
@@ -15,6 +16,13 @@ const SERVICE_CANNOT_BE_ADDED_ERROR_MESSAGE = 'Service cannot be added to quote
15
16
  const getBookingName = quoteName => escapeInvalidXmlChars(quoteName)
16
17
  .substring(0, MAX_BOOKING_NAME_LENGTH);
17
18
 
19
+ const buildAgentReference = ({ agentRef, reference }) => {
20
+ const source = typeof agentRef === 'string' && agentRef.trim() !== ''
21
+ ? agentRef
22
+ : reference;
23
+ return normalizeAgentReference(source);
24
+ };
25
+
18
26
  const addServiceToItinerary = async ({
19
27
  axios,
20
28
  token: {
@@ -30,6 +38,7 @@ const addServiceToItinerary = async ({
30
38
  quoteId,
31
39
  optionId,
32
40
  startDate,
41
+ agentRef,
33
42
  reference,
34
43
  /*
35
44
  paxConfigs: [{ roomType: 'DB', adults: 2 }, { roomType: 'TW', children: 2 }]
@@ -66,6 +75,20 @@ const addServiceToItinerary = async ({
66
75
  return acc;
67
76
  }, {});
68
77
 
78
+ const directLinePayloadHasAgentReference = directLinePayload &&
79
+ Object.prototype.hasOwnProperty.call(directLinePayload, 'AgentRef');
80
+ const customFieldsHaveAgentReference = Object.prototype.hasOwnProperty.call(
81
+ cfvPerService,
82
+ 'AgentRef',
83
+ );
84
+ const payloadAgentReference = buildAgentReference({ agentRef, reference });
85
+ let agentReferenceSource = payloadAgentReference;
86
+ if (directLinePayloadHasAgentReference) agentReferenceSource = directLinePayload.AgentRef;
87
+ if (customFieldsHaveAgentReference) agentReferenceSource = cfvPerService.AgentRef;
88
+ const agentReference = normalizeAgentReference(agentReferenceSource);
89
+ const directLinePayloadWithoutAgentReference = R.omit(['AgentRef'], directLinePayload || {});
90
+ const customFieldsWithoutAgentReference = R.omit(['AgentRef'], cfvPerService);
91
+
69
92
  const directHeaderPayloadHasName = directHeaderPayload &&
70
93
  Object.prototype.hasOwnProperty.call(directHeaderPayload, 'Name');
71
94
  const bookingNameSource = directHeaderPayloadHasName ? directHeaderPayload.Name : quoteName;
@@ -178,10 +201,10 @@ const addServiceToItinerary = async ({
178
201
  if (Number.isNaN(num) || num < 1) return 1;
179
202
  return num;
180
203
  })(),
181
- AgentRef: escapeInvalidXmlChars(reference),
204
+ ...(agentReference ? { AgentRef: agentReference } : {}),
182
205
  RoomConfigs: getRoomConfigs(paxConfigs),
183
- ...(directLinePayload || {}),
184
- ...(cfvPerService || {}),
206
+ ...directLinePayloadWithoutAgentReference,
207
+ ...customFieldsWithoutAgentReference,
185
208
  },
186
209
  };
187
210
  const replyObj = await callTourplan({
@@ -1,11 +1,16 @@
1
1
  const R = require('ramda');
2
2
  const Promise = require('bluebird');
3
- const { translateTPOption, getOptionCurrency } = require('./resolvers/product');
3
+ const {
4
+ attachOptionCatalogFields,
5
+ translateTPOption,
6
+ getOptionCurrency,
7
+ } = require('./resolvers/product');
4
8
  const { getAgentCurrencyCode } = require('./availability/itinerary-availability-helper');
5
9
  const { hostConnectXmlOptions } = require('./utils');
6
10
  const { getCachedLocations } = require('./tp-helpers/locations');
7
11
  const { getCachedServices } = require('./tp-helpers/services');
8
12
  const { getCachedDestinationCountries } = require('./tp-helpers/system-settings');
13
+ const { getCachedSuppliers } = require('./tp-helpers/suppliers');
9
14
  const { enrichOptionWithCodeTables } = require('./tp-helpers/option-enrichment');
10
15
  const { addStructuredOptionMetadata } = require('./option-metadata');
11
16
  const {
@@ -167,7 +172,12 @@ const searchProductsForItinerary = async ({
167
172
  return {};
168
173
  }
169
174
  };
170
- const [locationsByCode, resolvedServicesByCode, countriesByDestination] = await Promise.all([
175
+ const [
176
+ locationsByCode,
177
+ resolvedServicesByCode,
178
+ countriesByDestination,
179
+ suppliersById,
180
+ ] = await Promise.all([
171
181
  loadOptionalTable('locations', () => (
172
182
  getCachedLocations({
173
183
  callTourplan,
@@ -200,6 +210,16 @@ const searchProductsForItinerary = async ({
200
210
  hostConnectAgentPassword,
201
211
  })
202
212
  )),
213
+ loadOptionalTable('suppliers', () => (
214
+ getCachedSuppliers({
215
+ callTourplan,
216
+ cache,
217
+ axios,
218
+ hostConnectEndpoint,
219
+ hostConnectAgentID,
220
+ hostConnectAgentPassword,
221
+ })
222
+ )),
203
223
  ]);
204
224
  servicesByCode = resolvedServicesByCode;
205
225
 
@@ -217,14 +237,21 @@ const searchProductsForItinerary = async ({
217
237
  ), enrichedOptions);
218
238
  const products = await Promise.map(
219
239
  arrayOfOptionsGroupedBySupplierId,
220
- optionsGroupedBySupplierId => translateTPOption({
221
- rootValue: {
222
- optionsGroupedBySupplierId,
223
- },
224
- agentCurrencyCode,
225
- typeDefs: itineraryProductTypeDefs,
226
- query: itineraryProductQuery,
227
- }),
240
+ optionsGroupedBySupplierId => {
241
+ const supplierId = R.path([0, 'OptGeneral', 'SupplierId'], optionsGroupedBySupplierId);
242
+ const supplierKey = supplierId === undefined || supplierId === null
243
+ ? ''
244
+ : String(supplierId).trim();
245
+ return translateTPOption({
246
+ rootValue: {
247
+ optionsGroupedBySupplierId,
248
+ supplierRecord: suppliersById[supplierKey] || {},
249
+ },
250
+ agentCurrencyCode,
251
+ typeDefs: itineraryProductTypeDefs,
252
+ query: itineraryProductQuery,
253
+ });
254
+ },
228
255
  {
229
256
  concurrency: 10,
230
257
  },
@@ -232,7 +259,8 @@ const searchProductsForItinerary = async ({
232
259
  // Preserve raw OptRates / PickupPoints from Tourplan in product search response
233
260
  // when available (camelCase keys: optRates, pickupPoints; Tourplan field casing
234
261
  // inside). Re-attach after GraphQL because stock TI2 itinerary-product
235
- // typeDefs/query omit these fields (same for city/country/currency).
262
+ // typeDefs/query omit these fields (same for city/country/currency,
263
+ // optionClass, chargeUnit, Other, and maxPaxWithInfants).
236
264
  // Also ensure city and currency are always on the option for product cache.
237
265
  const enrichedByOptionId = R.indexBy(R.prop('Opt'), enrichedOptions);
238
266
  const optionRatesByOptionId = options.reduce((acc, option) => {
@@ -261,7 +289,7 @@ const searchProductsForItinerary = async ({
261
289
  || R.path(['PickupPoints'], rawOption),
262
290
  )
263
291
  : undefined;
264
- return addStructuredOptionMetadata({
292
+ return addStructuredOptionMetadata(attachOptionCatalogFields({
265
293
  ...R.omit(['city', 'country', 'rateContext'], currentOption),
266
294
  ...(city ? { city } : {}),
267
295
  ...(country ? { country } : {}),
@@ -270,7 +298,7 @@ const searchProductsForItinerary = async ({
270
298
  ...(R.path([currentOption.optionId], optionRatesByOptionId)
271
299
  ? { optRates: R.path([currentOption.optionId], optionRatesByOptionId) }
272
300
  : {}),
273
- });
301
+ }, rawOption));
274
302
  }),
275
303
  }));
276
304
  return {