ti2-tourplan 1.0.124 → 1.0.126

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.
@@ -0,0 +1,77 @@
1
+ <!-- LONTRDAVIDSHDWBVD: Fixture for searchProductsForItinerary Test -->
2
+ <?xml version="1.0" encoding="UTF-8"?>
3
+ <!DOCTYPE Reply SYSTEM "http://localhost:8080/iCom_Test/hostConnect_4_06_009.dtd">
4
+ <Reply>
5
+ <OptionInfoReply>
6
+ <Option>
7
+ <Opt>LONTRDAVIDSHDWBVD</Opt>
8
+ <OptionNumber>70820</OptionNumber>
9
+ <OptGeneral>
10
+ <SupplierId>6489</SupplierId>
11
+ <SupplierName>Davids of London Ltd</SupplierName>
12
+ <Description>Half-Day Warner Bros Studios (6-Hours)</Description>
13
+ <Comment>FIT- V- Class (1-5 Pax)</Comment>
14
+ <Locality>LON</Locality>
15
+ <LocalityDescription>London</LocalityDescription>
16
+ <SType>N</SType>
17
+ <MPFCU>5</MPFCU>
18
+ <SCU>day</SCU>
19
+ <MinSCU>1</MinSCU>
20
+ <MaxSCU>9999</MaxSCU>
21
+ <VoucherName>Davids of London Ltd</VoucherName>
22
+ <Address1>377 Fulham Palace Road</Address1>
23
+ <Address2></Address2>
24
+ <Address3>London</Address3>
25
+ <Address4>United Kingdom</Address4>
26
+ <Address5></Address5>
27
+ <PostCode>SW6 6TA</PostCode>
28
+ <InfantsAllowed>Y</InfantsAllowed>
29
+ <Infant_From>0</Infant_From>
30
+ <Infant_To>4</Infant_To>
31
+ <ChildrenAllowed>Y</ChildrenAllowed>
32
+ <Child_From>5</Child_From>
33
+ <Child_To>15</Child_To>
34
+ <AdultsAllowed>Y</AdultsAllowed>
35
+ <Adult_From>16</Adult_From>
36
+ <Adult_To>999</Adult_To>
37
+ <ButtonName>Transfers</ButtonName>
38
+ <DBAnalysisCode1>FI</DBAnalysisCode1>
39
+ <DBAnalysisDescription1>FIT</DBAnalysisDescription1>
40
+ <DBAnalysisCode2>GB</DBAnalysisCode2>
41
+ <DBAnalysisDescription2>United Kingdom</DBAnalysisDescription2>
42
+ <DBAnalysisCode3>20</DBAnalysisCode3>
43
+ <DBAnalysisDescription3>VAT 20%</DBAnalysisDescription3>
44
+ <DBAnalysisCode4></DBAnalysisCode4>
45
+ <DBAnalysisDescription4>Unassigned</DBAnalysisDescription4>
46
+ <DBAnalysisCode5></DBAnalysisCode5>
47
+ <DBAnalysisDescription5>Unassigned</DBAnalysisDescription5>
48
+ <DBAnalysisCode6></DBAnalysisCode6>
49
+ <DBAnalysisDescription6>Unassigned</DBAnalysisDescription6>
50
+ <LastUpdate>2023-11-23T14:34:53Z</LastUpdate>
51
+ </OptGeneral>
52
+ <OptRates>
53
+ <Currency>GBP</Currency>
54
+ <PaxBreaks>
55
+ <PaxBreak>1</PaxBreak>
56
+ </PaxBreaks>
57
+ <CancelPolicies>
58
+ <CancelPenalty>
59
+ <CancelRuleType>Percent</CancelRuleType>
60
+ <CancelRuleUnits>100.00</CancelRuleUnits>
61
+ </CancelPenalty>
62
+ </CancelPolicies>
63
+ <SaleFrom>2026-01-01</SaleFrom>
64
+ <SaleTo>2026-12-31</SaleTo>
65
+ <OptRate>
66
+ <Date>2026-06-01</Date>
67
+ <RateName>STD</RateName>
68
+ <RateText>Standard</RateText>
69
+ <CommissionPercent>10.00</CommissionPercent>
70
+ <PersonRates>
71
+ <AdultRate>10000</AdultRate>
72
+ </PersonRates>
73
+ </OptRate>
74
+ </OptRates>
75
+ </Option>
76
+ </OptionInfoReply>
77
+ </Reply>
@@ -4153,6 +4153,29 @@ Object {
4153
4153
  "comment": "FIT- V- Class (1-5 Pax)",
4154
4154
  "extras": Array [],
4155
4155
  "lastUpdateTimestamp": 1700750093,
4156
+ "optRates": Object {
4157
+ "CancelPolicies": Object {
4158
+ "CancelPenalty": Object {
4159
+ "CancelRuleType": "Percent",
4160
+ "CancelRuleUnits": 100,
4161
+ },
4162
+ },
4163
+ "Currency": "GBP",
4164
+ "OptRate": Object {
4165
+ "CommissionPercent": 10,
4166
+ "Date": "2026-06-01",
4167
+ "PersonRates": Object {
4168
+ "AdultRate": 10000,
4169
+ },
4170
+ "RateName": "STD",
4171
+ "RateText": "Standard",
4172
+ },
4173
+ "PaxBreaks": Object {
4174
+ "PaxBreak": 1,
4175
+ },
4176
+ "SaleFrom": "2026-01-01",
4177
+ "SaleTo": "2026-12-31",
4178
+ },
4156
4179
  "optionId": "LONTRDAVIDSHDWBVD",
4157
4180
  "optionName": "Half-Day Warner Bros Studios (6-Hours)-FIT- V- Class (1-5 Pax)",
4158
4181
  "restrictions": Object {
@@ -172,7 +172,7 @@ const getAvailabilityOnly = async ({
172
172
  const model = {
173
173
  OptionInfoRequest: {
174
174
  Opt: optionId,
175
- Info: 'AD',
175
+ Info: 'GAR',
176
176
  AgentID: hostConnectAgentID,
177
177
  Password: hostConnectAgentPassword,
178
178
  DateFrom: startDate,
@@ -186,7 +186,11 @@ const getAvailabilityOnly = async ({
186
186
  axios,
187
187
  xmlOptions: hostConnectXmlOptions,
188
188
  });
189
- return R.pathOr([], ['OptionInfoReply', 'Option', 'OptAvail'], replyObj);
189
+ const option = R.path(['OptionInfoReply', 'Option'], replyObj);
190
+ return {
191
+ optAvail: R.pathOr([], ['OptAvail'], option),
192
+ optRates: R.path(['OptRates'], option),
193
+ };
190
194
  };
191
195
  /*
192
196
  Get general option information from Tourplan API.
@@ -152,7 +152,7 @@ const searchAvailabilityForItinerary = async ({
152
152
  const isAvailabilityOnly = availabilityOnly === true;
153
153
 
154
154
  if (isAvailabilityOnly) {
155
- const availabilityOnlyResponse = await getAvailabilityOnly({
155
+ const { optAvail, optRates } = await getAvailabilityOnly({
156
156
  optionId,
157
157
  hostConnectEndpoint,
158
158
  hostConnectAgentID,
@@ -162,8 +162,9 @@ const searchAvailabilityForItinerary = async ({
162
162
  requestedEndDate,
163
163
  callTourplan,
164
164
  });
165
- const optAvailValues = getOptAvailValues(availabilityOnlyResponse);
165
+ const optAvailValues = getOptAvailValues(optAvail);
166
166
  const SCheckPass = isOptAvailBookable(optAvailValues);
167
+ const rates = (SCheckPass && optRates) ? [optRates] : [];
167
168
  return {
168
169
  bookable: SCheckPass,
169
170
  type: 'availability',
@@ -172,7 +173,7 @@ const searchAvailabilityForItinerary = async ({
172
173
  ? 'Availability checked successfully'
173
174
  : 'No availability found for the requested range',
174
175
  availability: optAvailValues,
175
- rates: [],
176
+ rates,
176
177
  };
177
178
  }
178
179
 
@@ -24,7 +24,7 @@ jest.mock('./itinerary-availability-helper', () => ({
24
24
  maxPaxPerCharge: null,
25
25
  })),
26
26
  getNoRatesAvailableError: jest.fn(async () => 'No rates available'),
27
- getAvailabilityOnly: jest.fn(async () => '-1 -1 -1'),
27
+ getAvailabilityOnly: jest.fn(async () => ({ optAvail: '-1 -1 -1', optRates: null })),
28
28
  getStayResults: jest.fn(async () => ([{
29
29
  RateId: 'R1',
30
30
  Currency: 'USD',
@@ -149,7 +149,18 @@ describe('searchAvailabilityForItinerary validation flags', () => {
149
149
  });
150
150
 
151
151
  it('uses availabilityOnly flow and keeps response shape consistent', async () => {
152
- itineraryAvailabilityHelper.getAvailabilityOnly.mockResolvedValueOnce('-1 -2 -1');
152
+ const mockOptRates = {
153
+ Currency: 'JPY',
154
+ OptRate: {
155
+ Date: '2026-06-01',
156
+ RateName: '26年',
157
+ PersonRates: { AdultRate: 2700000, ChildRate: 1700000 },
158
+ },
159
+ };
160
+ itineraryAvailabilityHelper.getAvailabilityOnly.mockResolvedValueOnce({
161
+ optAvail: '-1 -2 -1',
162
+ optRates: mockOptRates,
163
+ });
153
164
 
154
165
  const result = await searchAvailabilityForItinerary({
155
166
  axios: jest.fn(),
@@ -160,6 +171,7 @@ describe('searchAvailabilityForItinerary validation flags', () => {
160
171
  });
161
172
 
162
173
  expect(itineraryAvailabilityHelper.getAvailabilityOnly).toHaveBeenCalledTimes(1);
174
+ expect(itineraryAvailabilityHelper.getStayResults).not.toHaveBeenCalled();
163
175
  expect(itineraryAvailabilityHelper.getAgentCurrencyCode).not.toHaveBeenCalled();
164
176
  expect(result).toEqual({
165
177
  bookable: true,
@@ -167,12 +179,15 @@ describe('searchAvailabilityForItinerary validation flags', () => {
167
179
  endDate: '2025-04-05',
168
180
  message: 'Availability checked successfully',
169
181
  availability: [-1, -2, -1],
170
- rates: [],
182
+ rates: [mockOptRates],
171
183
  });
172
184
  });
173
185
 
174
186
  it('marks availabilityOnly response as not bookable when all days are unavailable', async () => {
175
- itineraryAvailabilityHelper.getAvailabilityOnly.mockResolvedValueOnce('-1 -1 -1');
187
+ itineraryAvailabilityHelper.getAvailabilityOnly.mockResolvedValueOnce({
188
+ optAvail: '-1 -1 -1',
189
+ optRates: null,
190
+ });
176
191
 
177
192
  const result = await searchAvailabilityForItinerary({
178
193
  axios: jest.fn(),
@@ -186,5 +201,6 @@ describe('searchAvailabilityForItinerary validation flags', () => {
186
201
  expect(result.message).toBe('No availability found for the requested range');
187
202
  expect(result.availability).toEqual([-1, -1, -1]);
188
203
  expect(result.rates).toEqual([]);
204
+ expect(itineraryAvailabilityHelper.getStayResults).not.toHaveBeenCalled();
189
205
  });
190
206
  });
@@ -18,7 +18,6 @@ const searchProductsForItinerary = async ({
18
18
  payload: {
19
19
  // single optionId or array of optionIds
20
20
  optionId,
21
- forceRefresh,
22
21
  searchInput,
23
22
  // lastUpdatedFrom is used to get options that were updated after a certain date in Tourplan
24
23
  // example: lastUpdatedFrom: '2024-04-22 05:17:57.427Z'
@@ -26,55 +25,84 @@ const searchProductsForItinerary = async ({
26
25
  },
27
26
  callTourplan,
28
27
  }) => {
29
- /*
30
- Pseudo
31
- 1. getServiceCodes -> [AC, BD]
32
- 2. for each serviceCode getoptions
33
- 3. convert them to ti2 products structure
34
- 4. merge all products from all serviceCodes
35
- */
36
- // getServices
37
- const getServicesModel = {
38
- GetServicesRequest: {
39
- AgentID: hostConnectAgentID,
40
- Password: hostConnectAgentPassword,
41
- },
42
- };
43
- const getServicesReply = await callTourplan({
44
- model: getServicesModel,
45
- endpoint: hostConnectEndpoint,
46
- axios,
47
- xmlOptions: hostConnectXmlOptions,
48
- });
49
- let serviceCodes = R.pathOr([], ['GetServicesReply', 'TPLServices', 'TPLService'], getServicesReply);
50
- if (!Array.isArray(serviceCodes)) serviceCodes = [serviceCodes];
51
- serviceCodes = serviceCodes.map(s => s.Code);
28
+ // Normalise optionId to an array (or empty)
29
+ const optionIds = optionId
30
+ ? (Array.isArray(optionId) ? optionId : [optionId]).filter(Boolean)
31
+ : [];
32
+
52
33
  let options = [];
53
- await Promise.each(serviceCodes, async serviceCode => {
54
- const getOptionsModel = {
55
- OptionInfoRequest: {
56
- Opt: `???${serviceCode}????????????`,
57
- Info: 'G',
34
+
35
+ if (optionIds.length > 0) {
36
+ // Fast path: fetch only the requested option(s) by their exact Opt code,
37
+ // skipping the expensive full-catalog GetServices + wildcard OptionInfo calls.
38
+ await Promise.each(optionIds, async id => {
39
+ const getOptionsModel = {
40
+ OptionInfoRequest: {
41
+ Opt: id,
42
+ Info: 'GR',
43
+ AgentID: hostConnectAgentID,
44
+ Password: hostConnectAgentPassword,
45
+ },
46
+ };
47
+ const getOptionsReply = await callTourplan({
48
+ model: getOptionsModel,
49
+ endpoint: hostConnectEndpoint,
50
+ axios,
51
+ xmlOptions: hostConnectXmlOptions,
52
+ });
53
+ let thisOptions = R.pathOr([], ['OptionInfoReply', 'Option'], getOptionsReply);
54
+ if (!Array.isArray(thisOptions)) thisOptions = [thisOptions];
55
+ options = options.concat(thisOptions);
56
+ });
57
+ } else {
58
+ /*
59
+ Full catalog path:
60
+ 1. getServiceCodes -> [AC, BD]
61
+ 2. for each serviceCode getoptions
62
+ 3. convert them to ti2 products structure
63
+ 4. merge all products from all serviceCodes
64
+ */
65
+ // getServices
66
+ const getServicesModel = {
67
+ GetServicesRequest: {
58
68
  AgentID: hostConnectAgentID,
59
69
  Password: hostConnectAgentPassword,
60
- ...(lastUpdatedFrom ? {
61
- LastUpdateFrom: lastUpdatedFrom,
62
- } : {}),
63
70
  },
64
71
  };
65
- const getOptionsReply = await callTourplan({
66
- model: getOptionsModel,
72
+ const getServicesReply = await callTourplan({
73
+ model: getServicesModel,
67
74
  endpoint: hostConnectEndpoint,
68
75
  axios,
69
76
  xmlOptions: hostConnectXmlOptions,
70
77
  });
71
- let thisOptions = R.pathOr([], ['OptionInfoReply', 'Option'], getOptionsReply);
72
- // due to the new parser, single option will be returned as an object
73
- // instead of an array
74
- if (!Array.isArray(thisOptions)) thisOptions = [thisOptions];
75
- console.log(`got ${thisOptions.length} options for serviceCode ${serviceCode}`);
76
- options = options.concat(thisOptions);
77
- });
78
+ let serviceCodes = R.pathOr([], ['GetServicesReply', 'TPLServices', 'TPLService'], getServicesReply);
79
+ if (!Array.isArray(serviceCodes)) serviceCodes = [serviceCodes];
80
+ serviceCodes = serviceCodes.map(s => s.Code);
81
+ await Promise.each(serviceCodes, async serviceCode => {
82
+ const getOptionsModel = {
83
+ OptionInfoRequest: {
84
+ Opt: `???${serviceCode}????????????`,
85
+ Info: 'G',
86
+ AgentID: hostConnectAgentID,
87
+ Password: hostConnectAgentPassword,
88
+ ...(lastUpdatedFrom ? {
89
+ LastUpdateFrom: lastUpdatedFrom,
90
+ } : {}),
91
+ },
92
+ };
93
+ const getOptionsReply = await callTourplan({
94
+ model: getOptionsModel,
95
+ endpoint: hostConnectEndpoint,
96
+ axios,
97
+ xmlOptions: hostConnectXmlOptions,
98
+ });
99
+ let thisOptions = R.pathOr([], ['OptionInfoReply', 'Option'], getOptionsReply);
100
+ // due to the new parser, single option will be returned as an object
101
+ // instead of an array
102
+ if (!Array.isArray(thisOptions)) thisOptions = [thisOptions];
103
+ options = options.concat(thisOptions);
104
+ });
105
+ }
78
106
  if (!(options && options.length)) {
79
107
  throw new Error('No products found');
80
108
  }
@@ -95,8 +123,27 @@ const searchProductsForItinerary = async ({
95
123
  concurrency: 10,
96
124
  },
97
125
  );
126
+ // Preserve raw OptRates from Tourplan in product search response when available.
127
+ const optionRatesByOptionId = options.reduce((acc, option) => {
128
+ const currentOptionId = R.path(['Opt'], option);
129
+ const currentOptionRates = R.path(['OptRates'], option);
130
+ if (!currentOptionId || !currentOptionRates) return acc;
131
+ return {
132
+ ...acc,
133
+ [currentOptionId]: currentOptionRates,
134
+ };
135
+ }, {});
136
+ const productsWithOptRates = products.map(product => ({
137
+ ...product,
138
+ options: R.pathOr([], ['options'], product).map(currentOption => ({
139
+ ...currentOption,
140
+ ...(R.path([currentOption.optionId], optionRatesByOptionId)
141
+ ? { optRates: R.path([currentOption.optionId], optionRatesByOptionId) }
142
+ : {}),
143
+ })),
144
+ }));
98
145
  return {
99
- products,
146
+ products: productsWithOptRates,
100
147
  productFields: [],
101
148
  ...(searchInput || optionId ? {} : configuration),
102
149
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2-tourplan",
3
- "version": "1.0.124",
3
+ "version": "1.0.126",
4
4
  "description": "Tourplan's TI2 Plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {