ti2-tourplan 1.0.123 → 1.0.125
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 +65 -0
- package/__fixtures__/OptionInfoRequest_4f8b93072db9a52be70dc1f563c4b374de4eb0e6.txt +77 -0
- package/__snapshots__/index.test.js.snap +23 -0
- package/index.js +20 -0
- package/index.test.js +157 -0
- package/itinerary-cancel-booking.js +6 -44
- package/itinerary-confirm-booking.js +72 -0
- package/itinerary-hostconnect-helpers.js +52 -0
- package/itinerary-products-search.js +89 -42
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -105,3 +105,68 @@ The plugin returns both:
|
|
|
105
105
|
- `status`: aggregate over all `ServiceStatus.Status` values:
|
|
106
106
|
- same status on all lines => that status (e.g. `XX`)
|
|
107
107
|
- mixed line statuses => `MIXED`
|
|
108
|
+
|
|
109
|
+
## Confirm Booking (HostConnect)
|
|
110
|
+
|
|
111
|
+
This plugin converts a quote to a confirmed booking using HostConnect `QuoteToBookRequest`. Inventory is allocated for each service line when the quote is confirmed; the returned `ServiceStatus` values indicate whether allocation succeeded per line.
|
|
112
|
+
|
|
113
|
+
### HostConnect XML Request
|
|
114
|
+
|
|
115
|
+
```xml
|
|
116
|
+
<Request>
|
|
117
|
+
<QuoteToBookRequest>
|
|
118
|
+
<AgentID>YOUR_AGENT_ID</AgentID>
|
|
119
|
+
<Password>YOUR_AGENT_PASSWORD</Password>
|
|
120
|
+
<BookingId>14226</BookingId>
|
|
121
|
+
</QuoteToBookRequest>
|
|
122
|
+
</Request>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Equivalent curl:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
curl --location 'https://<HOSTCONNECT_ENDPOINT>/api/hostConnectApi' \
|
|
129
|
+
--header 'Content-Type: application/xml; charset=utf-8' \
|
|
130
|
+
--header 'requestId: <uuid>' \
|
|
131
|
+
--header 'Accept: application/xml' \
|
|
132
|
+
--data '<Request>
|
|
133
|
+
<QuoteToBookRequest>
|
|
134
|
+
<AgentID>YOUR_AGENT_ID</AgentID>
|
|
135
|
+
<Password>YOUR_AGENT_PASSWORD</Password>
|
|
136
|
+
<BookingId>14226</BookingId>
|
|
137
|
+
</QuoteToBookRequest>
|
|
138
|
+
</Request>'
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### HostConnect XML Response (example)
|
|
142
|
+
|
|
143
|
+
```xml
|
|
144
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
145
|
+
<!DOCTYPE Reply SYSTEM "hostConnect_5_05_010.dtd">
|
|
146
|
+
<Reply>
|
|
147
|
+
<QuoteToBookReply>
|
|
148
|
+
<BookingId>14226</BookingId>
|
|
149
|
+
<Ref>A2IN111975</Ref>
|
|
150
|
+
<ServiceStatuses>
|
|
151
|
+
<ServiceStatus>
|
|
152
|
+
<Ref>A2IN111975</Ref>
|
|
153
|
+
<ServiceLineId>61738</ServiceLineId>
|
|
154
|
+
<Date>2026-07-03</Date>
|
|
155
|
+
<SequenceNumber>10</SequenceNumber>
|
|
156
|
+
<Status>OK</Status>
|
|
157
|
+
</ServiceStatus>
|
|
158
|
+
</ServiceStatuses>
|
|
159
|
+
</QuoteToBookReply>
|
|
160
|
+
</Reply>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Plugin Return Shape
|
|
164
|
+
|
|
165
|
+
The plugin returns both:
|
|
166
|
+
|
|
167
|
+
- `confirmBookingReply`: raw parsed HostConnect `QuoteToBookReply` object
|
|
168
|
+
- `booking`: normalized object
|
|
169
|
+
- `ref`: from `Ref`
|
|
170
|
+
- `id`: from `BookingId` (or the identifier sent in the request)
|
|
171
|
+
- `status`: aggregate over all `ServiceStatus.Status` values (same rules as cancel), with fallbacks to `BookingStatus`, `Status`, `payload.status`, then `'Confirmed'`
|
|
172
|
+
- `serviceLines`: array of `{ ref, serviceLineId, date, sequenceNumber, status }`
|
|
@@ -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 {
|
package/index.js
CHANGED
|
@@ -11,6 +11,7 @@ const Normalizer = require('./normalizer');
|
|
|
11
11
|
const { searchAvailabilityForItinerary } = require('./availability/itinerary-availability');
|
|
12
12
|
const { addServiceToItinerary } = require('./itinerary-add-service');
|
|
13
13
|
const { cancelBooking } = require('./itinerary-cancel-booking');
|
|
14
|
+
const { confirmBooking } = require('./itinerary-confirm-booking');
|
|
14
15
|
const { searchProductsForItinerary } = require('./itinerary-products-search');
|
|
15
16
|
const { searchItineraries } = require('./itinerary-search');
|
|
16
17
|
const { hostConnectXmlOptions } = require('./utils');
|
|
@@ -680,6 +681,9 @@ class BuyerPlugin {
|
|
|
680
681
|
});
|
|
681
682
|
}
|
|
682
683
|
|
|
684
|
+
/**
|
|
685
|
+
* Cancel a booking.
|
|
686
|
+
*/
|
|
683
687
|
async cancelBooking({
|
|
684
688
|
axios,
|
|
685
689
|
token,
|
|
@@ -693,6 +697,22 @@ class BuyerPlugin {
|
|
|
693
697
|
});
|
|
694
698
|
}
|
|
695
699
|
|
|
700
|
+
/**
|
|
701
|
+
* Convert a quote to a confirmed booking.
|
|
702
|
+
*/
|
|
703
|
+
async confirmBooking({
|
|
704
|
+
axios,
|
|
705
|
+
token,
|
|
706
|
+
payload,
|
|
707
|
+
}) {
|
|
708
|
+
return confirmBooking({
|
|
709
|
+
axios,
|
|
710
|
+
token,
|
|
711
|
+
payload,
|
|
712
|
+
callTourplan: this.callTourplan.bind(this),
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
696
716
|
async searchItineraries({
|
|
697
717
|
axios,
|
|
698
718
|
typeDefsAndQueries,
|
package/index.test.js
CHANGED
|
@@ -274,6 +274,163 @@ describe('search tests', () => {
|
|
|
274
274
|
},
|
|
275
275
|
});
|
|
276
276
|
});
|
|
277
|
+
|
|
278
|
+
it('requires a booking id or reference', async () => {
|
|
279
|
+
await expect(app.cancelBooking({
|
|
280
|
+
axios,
|
|
281
|
+
token,
|
|
282
|
+
payload: {},
|
|
283
|
+
})).rejects.toThrow('Must provide booking id or reference for cancellation');
|
|
284
|
+
expect(mockCallTourplan).not.toHaveBeenCalled();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
describe('confirmBooking', () => {
|
|
288
|
+
it('converts a quote to a booking by id', async () => {
|
|
289
|
+
mockCallTourplan.mockImplementationOnce(async () => ({
|
|
290
|
+
QuoteToBookReply: {
|
|
291
|
+
BookingId: '14226',
|
|
292
|
+
Ref: 'A2IN111975',
|
|
293
|
+
ServiceStatuses: {
|
|
294
|
+
ServiceStatus: {
|
|
295
|
+
Ref: 'A2IN111975',
|
|
296
|
+
ServiceLineId: '61738',
|
|
297
|
+
Date: '2026-07-03',
|
|
298
|
+
SequenceNumber: '10',
|
|
299
|
+
Status: 'OK',
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
}));
|
|
304
|
+
const retVal = await app.confirmBooking({
|
|
305
|
+
axios,
|
|
306
|
+
token,
|
|
307
|
+
payload: {
|
|
308
|
+
bookingId: '14226',
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
expect(mockCallTourplan).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
|
312
|
+
model: {
|
|
313
|
+
QuoteToBookRequest: expect.objectContaining({
|
|
314
|
+
BookingId: '14226',
|
|
315
|
+
}),
|
|
316
|
+
},
|
|
317
|
+
}));
|
|
318
|
+
expect(retVal).toEqual({
|
|
319
|
+
confirmBookingReply: {
|
|
320
|
+
BookingId: '14226',
|
|
321
|
+
Ref: 'A2IN111975',
|
|
322
|
+
ServiceStatuses: {
|
|
323
|
+
ServiceStatus: {
|
|
324
|
+
Ref: 'A2IN111975',
|
|
325
|
+
ServiceLineId: '61738',
|
|
326
|
+
Date: '2026-07-03',
|
|
327
|
+
SequenceNumber: '10',
|
|
328
|
+
Status: 'OK',
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
booking: {
|
|
333
|
+
ref: 'A2IN111975',
|
|
334
|
+
id: '14226',
|
|
335
|
+
status: 'OK',
|
|
336
|
+
serviceLines: [{
|
|
337
|
+
ref: 'A2IN111975',
|
|
338
|
+
serviceLineId: '61738',
|
|
339
|
+
date: '2026-07-03',
|
|
340
|
+
sequenceNumber: '10',
|
|
341
|
+
status: 'OK',
|
|
342
|
+
}],
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('converts a quote to a booking by ref', async () => {
|
|
348
|
+
mockCallTourplan.mockImplementationOnce(async () => ({
|
|
349
|
+
QuoteToBookReply: {
|
|
350
|
+
BookingId: '14226',
|
|
351
|
+
Ref: 'A2IN111975',
|
|
352
|
+
ServiceStatuses: {
|
|
353
|
+
ServiceStatus: [{
|
|
354
|
+
ServiceLineId: '61738',
|
|
355
|
+
Date: '2026-07-03',
|
|
356
|
+
SequenceNumber: '10',
|
|
357
|
+
Status: 'OK',
|
|
358
|
+
}],
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
}));
|
|
362
|
+
const retVal = await app.confirmBooking({
|
|
363
|
+
axios,
|
|
364
|
+
token,
|
|
365
|
+
payload: {
|
|
366
|
+
ref: 'A2IN111975',
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
expect(mockCallTourplan).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
|
370
|
+
model: {
|
|
371
|
+
QuoteToBookRequest: expect.objectContaining({
|
|
372
|
+
Ref: 'A2IN111975',
|
|
373
|
+
}),
|
|
374
|
+
},
|
|
375
|
+
}));
|
|
376
|
+
expect(retVal.booking.id).toBe('14226');
|
|
377
|
+
expect(retVal.booking.ref).toBe('A2IN111975');
|
|
378
|
+
expect(retVal.booking.status).toBe('OK');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('returns MIXED status when service line statuses differ', async () => {
|
|
382
|
+
mockCallTourplan.mockImplementationOnce(async () => ({
|
|
383
|
+
QuoteToBookReply: {
|
|
384
|
+
BookingId: '14226',
|
|
385
|
+
Ref: 'A2IN111975',
|
|
386
|
+
ServiceStatuses: {
|
|
387
|
+
ServiceStatus: [{
|
|
388
|
+
ServiceLineId: '61738',
|
|
389
|
+
Status: 'OK',
|
|
390
|
+
}, {
|
|
391
|
+
ServiceLineId: '61739',
|
|
392
|
+
Status: 'RQ',
|
|
393
|
+
}],
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
}));
|
|
397
|
+
const retVal = await app.confirmBooking({
|
|
398
|
+
axios,
|
|
399
|
+
token,
|
|
400
|
+
payload: {
|
|
401
|
+
bookingId: '14226',
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
expect(retVal.booking.status).toBe('MIXED');
|
|
405
|
+
expect(retVal.booking.serviceLines).toHaveLength(2);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('requires a booking id or reference', async () => {
|
|
409
|
+
await expect(app.confirmBooking({
|
|
410
|
+
axios,
|
|
411
|
+
token,
|
|
412
|
+
payload: {},
|
|
413
|
+
})).rejects.toThrow('Must provide booking id or reference for confirm booking');
|
|
414
|
+
expect(mockCallTourplan).not.toHaveBeenCalled();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('falls back to Confirmed when no service statuses are returned', async () => {
|
|
418
|
+
mockCallTourplan.mockImplementationOnce(async () => ({
|
|
419
|
+
QuoteToBookReply: {
|
|
420
|
+
BookingId: '14226',
|
|
421
|
+
Ref: 'A2IN111975',
|
|
422
|
+
},
|
|
423
|
+
}));
|
|
424
|
+
const retVal = await app.confirmBooking({
|
|
425
|
+
axios,
|
|
426
|
+
token,
|
|
427
|
+
payload: {
|
|
428
|
+
bookingId: '14226',
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
expect(retVal.booking.status).toBe('Confirmed');
|
|
432
|
+
expect(retVal.booking.serviceLines).toEqual([]);
|
|
433
|
+
});
|
|
277
434
|
});
|
|
278
435
|
describe('template tests', () => {
|
|
279
436
|
let template;
|
|
@@ -2,49 +2,17 @@ const assert = require('assert');
|
|
|
2
2
|
const R = require('ramda');
|
|
3
3
|
const {
|
|
4
4
|
hostConnectXmlOptions,
|
|
5
|
-
escapeInvalidXmlChars,
|
|
6
5
|
} = require('./utils');
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return {
|
|
12
|
-
field: 'BookingId',
|
|
13
|
-
value: String(bookingId),
|
|
14
|
-
};
|
|
15
|
-
}
|
|
16
|
-
const bookingRef = payload.ref || payload.reference;
|
|
17
|
-
if (bookingRef) {
|
|
18
|
-
return {
|
|
19
|
-
field: 'Ref',
|
|
20
|
-
value: escapeInvalidXmlChars(String(bookingRef)),
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
return null;
|
|
24
|
-
};
|
|
6
|
+
const {
|
|
7
|
+
getBookingIdentifier,
|
|
8
|
+
getAggregateServiceStatus,
|
|
9
|
+
} = require('./itinerary-hostconnect-helpers');
|
|
25
10
|
|
|
26
11
|
const extractCancellationReply = replyObj => (
|
|
27
12
|
R.path(['CancelServicesReply'], replyObj)
|
|
28
13
|
|| {}
|
|
29
14
|
);
|
|
30
15
|
|
|
31
|
-
const getServiceStatusList = cancellationReply => {
|
|
32
|
-
let serviceStatuses = R.pathOr([], ['ServiceStatuses', 'ServiceStatus'], cancellationReply);
|
|
33
|
-
if (!Array.isArray(serviceStatuses)) serviceStatuses = [serviceStatuses];
|
|
34
|
-
return serviceStatuses
|
|
35
|
-
.map(serviceStatus => R.path(['Status'], serviceStatus))
|
|
36
|
-
.filter(Boolean)
|
|
37
|
-
.map(status => String(status).toUpperCase());
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const getAggregateServiceStatus = cancellationReply => {
|
|
41
|
-
const statuses = getServiceStatusList(cancellationReply);
|
|
42
|
-
if (!statuses.length) return null;
|
|
43
|
-
const uniqueStatuses = [...new Set(statuses)];
|
|
44
|
-
if (uniqueStatuses.length === 1) return uniqueStatuses[0];
|
|
45
|
-
return 'MIXED';
|
|
46
|
-
};
|
|
47
|
-
|
|
48
16
|
const normalizeCancellationResponse = (replyObj, payload, identifier) => {
|
|
49
17
|
const cancellationReply = extractCancellationReply(replyObj);
|
|
50
18
|
const aggregateServiceStatus = getAggregateServiceStatus(cancellationReply);
|
|
@@ -62,13 +30,7 @@ const normalizeCancellationResponse = (replyObj, payload, identifier) => {
|
|
|
62
30
|
};
|
|
63
31
|
|
|
64
32
|
/**
|
|
65
|
-
* Cancels a booking by id or reference
|
|
66
|
-
* @param {Object} params - The parameters for the cancellation
|
|
67
|
-
* @param {Object} params.axios - The axios instance
|
|
68
|
-
* @param {Object} params.token - The token for the cancellation
|
|
69
|
-
* @param {Object} params.payload - The payload for the cancellation
|
|
70
|
-
* @param {Object} params.callTourplan - The callTourplan function
|
|
71
|
-
* @returns {Promise<Object>} The cancellation response
|
|
33
|
+
* Cancels a booking by id or reference via HostConnect CancelServicesRequest.
|
|
72
34
|
*/
|
|
73
35
|
const cancelBooking = async ({
|
|
74
36
|
axios,
|
|
@@ -83,7 +45,7 @@ const cancelBooking = async ({
|
|
|
83
45
|
assert(hostConnectEndpoint, 'Must provide token.hostConnectEndpoint for booking cancellation');
|
|
84
46
|
assert(hostConnectAgentID, 'Must provide token.hostConnectAgentID for booking cancellation');
|
|
85
47
|
assert(hostConnectAgentPassword, 'Must provide token.hostConnectAgentPassword for booking cancellation');
|
|
86
|
-
const identifier =
|
|
48
|
+
const identifier = getBookingIdentifier(payload);
|
|
87
49
|
assert(identifier, 'Must provide booking id or reference for cancellation');
|
|
88
50
|
|
|
89
51
|
const baseRequest = {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const R = require('ramda');
|
|
3
|
+
const { hostConnectXmlOptions } = require('./utils');
|
|
4
|
+
const {
|
|
5
|
+
getBookingIdentifier,
|
|
6
|
+
getAggregateServiceStatus,
|
|
7
|
+
mapServiceStatusLines,
|
|
8
|
+
} = require('./itinerary-hostconnect-helpers');
|
|
9
|
+
|
|
10
|
+
const extractConfirmBookingReply = replyObj => (
|
|
11
|
+
R.path(['QuoteToBookReply'], replyObj)
|
|
12
|
+
|| {}
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const normalizeConfirmBookingResponse = (replyObj, payload, identifier) => {
|
|
16
|
+
const confirmBookingReply = extractConfirmBookingReply(replyObj);
|
|
17
|
+
const aggregateServiceStatus = getAggregateServiceStatus(confirmBookingReply);
|
|
18
|
+
return {
|
|
19
|
+
confirmBookingReply,
|
|
20
|
+
booking: {
|
|
21
|
+
ref: R.path(['Ref'], confirmBookingReply),
|
|
22
|
+
id: R.path(['BookingId'], confirmBookingReply) || identifier.value,
|
|
23
|
+
status: aggregateServiceStatus
|
|
24
|
+
|| R.path(['BookingStatus'], confirmBookingReply)
|
|
25
|
+
|| R.path(['Status'], confirmBookingReply)
|
|
26
|
+
|| payload.status
|
|
27
|
+
|| 'Confirmed',
|
|
28
|
+
serviceLines: mapServiceStatusLines(confirmBookingReply),
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Converts a quote to a confirmed booking via HostConnect QuoteToBookRequest.
|
|
35
|
+
* Inventory is allocated for each service line; returned ServiceStatus values
|
|
36
|
+
* indicate whether allocation succeeded per line.
|
|
37
|
+
*/
|
|
38
|
+
const confirmBooking = async ({
|
|
39
|
+
axios,
|
|
40
|
+
token: {
|
|
41
|
+
hostConnectEndpoint,
|
|
42
|
+
hostConnectAgentID,
|
|
43
|
+
hostConnectAgentPassword,
|
|
44
|
+
},
|
|
45
|
+
payload = {},
|
|
46
|
+
callTourplan,
|
|
47
|
+
}) => {
|
|
48
|
+
assert(hostConnectEndpoint, 'Must provide token.hostConnectEndpoint for confirm booking');
|
|
49
|
+
assert(hostConnectAgentID, 'Must provide token.hostConnectAgentID for confirm booking');
|
|
50
|
+
assert(hostConnectAgentPassword, 'Must provide token.hostConnectAgentPassword for confirm booking');
|
|
51
|
+
const identifier = getBookingIdentifier(payload);
|
|
52
|
+
assert(identifier, 'Must provide booking id or reference for confirm booking');
|
|
53
|
+
|
|
54
|
+
const baseRequest = {
|
|
55
|
+
AgentID: hostConnectAgentID,
|
|
56
|
+
Password: hostConnectAgentPassword,
|
|
57
|
+
[identifier.field]: identifier.value,
|
|
58
|
+
};
|
|
59
|
+
const replyObj = await callTourplan({
|
|
60
|
+
model: {
|
|
61
|
+
QuoteToBookRequest: baseRequest,
|
|
62
|
+
},
|
|
63
|
+
endpoint: hostConnectEndpoint,
|
|
64
|
+
axios,
|
|
65
|
+
xmlOptions: hostConnectXmlOptions,
|
|
66
|
+
});
|
|
67
|
+
return normalizeConfirmBookingResponse(replyObj, payload, identifier);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
confirmBooking,
|
|
72
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const R = require('ramda');
|
|
2
|
+
const { escapeInvalidXmlChars } = require('./utils');
|
|
3
|
+
|
|
4
|
+
const getBookingIdentifier = payload => {
|
|
5
|
+
const bookingId = payload.bookingId || payload.id;
|
|
6
|
+
if (bookingId) {
|
|
7
|
+
return {
|
|
8
|
+
field: 'BookingId',
|
|
9
|
+
value: String(bookingId),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const bookingRef = payload.ref || payload.reference;
|
|
13
|
+
if (bookingRef) {
|
|
14
|
+
return {
|
|
15
|
+
field: 'Ref',
|
|
16
|
+
value: escapeInvalidXmlChars(String(bookingRef)),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const toServiceStatusArray = reply => {
|
|
23
|
+
let serviceStatuses = R.pathOr([], ['ServiceStatuses', 'ServiceStatus'], reply);
|
|
24
|
+
if (!Array.isArray(serviceStatuses)) serviceStatuses = [serviceStatuses];
|
|
25
|
+
return serviceStatuses.filter(Boolean);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const getAggregateServiceStatus = reply => {
|
|
29
|
+
const statuses = toServiceStatusArray(reply)
|
|
30
|
+
.map(serviceStatus => R.path(['Status'], serviceStatus))
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.map(status => String(status).toUpperCase());
|
|
33
|
+
if (!statuses.length) return null;
|
|
34
|
+
const uniqueStatuses = [...new Set(statuses)];
|
|
35
|
+
if (uniqueStatuses.length === 1) return uniqueStatuses[0];
|
|
36
|
+
return 'MIXED';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const mapServiceStatusLines = reply => toServiceStatusArray(reply).map(serviceStatus => ({
|
|
40
|
+
ref: R.path(['Ref'], serviceStatus),
|
|
41
|
+
serviceLineId: R.path(['ServiceLineId'], serviceStatus),
|
|
42
|
+
date: R.path(['Date'], serviceStatus),
|
|
43
|
+
sequenceNumber: R.path(['SequenceNumber'], serviceStatus),
|
|
44
|
+
status: R.path(['Status'], serviceStatus),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
module.exports = {
|
|
48
|
+
getBookingIdentifier,
|
|
49
|
+
toServiceStatusArray,
|
|
50
|
+
getAggregateServiceStatus,
|
|
51
|
+
mapServiceStatusLines,
|
|
52
|
+
};
|
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
66
|
-
model:
|
|
72
|
+
const getServicesReply = await callTourplan({
|
|
73
|
+
model: getServicesModel,
|
|
67
74
|
endpoint: hostConnectEndpoint,
|
|
68
75
|
axios,
|
|
69
76
|
xmlOptions: hostConnectXmlOptions,
|
|
70
77
|
});
|
|
71
|
-
let
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.125",
|
|
4
4
|
"description": "Tourplan's TI2 Plugin",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"jsonwebtoken": "^8.5.1",
|
|
40
40
|
"moment": "^2.29.1",
|
|
41
41
|
"ramda": "^0.27.1",
|
|
42
|
-
"ti2": "^1.0.
|
|
42
|
+
"ti2": "^1.0.118",
|
|
43
43
|
"underscore": "^1.13.4",
|
|
44
44
|
"xml-js": "^1.6.11",
|
|
45
45
|
"xml2js": "^0.4.23"
|