ti2-tourplan 1.0.122 → 1.0.123

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 CHANGED
@@ -39,3 +39,69 @@ TL;DR Here's what the license entails:
39
39
  7. Any modifications of this code base MUST be distributed with the same license, GPLv3.
40
40
  8. This software is provided without warranty.
41
41
  9. The software author or license can not be held liable for any damages inflicted by the software.
42
+
43
+ ## Cancel Booking (HostConnect)
44
+
45
+ This plugin cancels bookings using `CancelServicesRequest` (not `CancelBookingRequest`).
46
+
47
+ ### HostConnect XML Request
48
+
49
+ ```xml
50
+ <Request>
51
+ <CancelServicesRequest>
52
+ <AgentID>YOUR_AGENT_ID</AgentID>
53
+ <Password>YOUR_AGENT_PASSWORD</Password>
54
+ <BookingId>14226</BookingId>
55
+ <ReturnBooking>Y</ReturnBooking>
56
+ </CancelServicesRequest>
57
+ </Request>
58
+ ```
59
+
60
+ Equivalent curl:
61
+
62
+ ```bash
63
+ curl --location 'https://<HOSTCONNECT_ENDPOINT>/api/hostConnectApi' \
64
+ --header 'Content-Type: application/xml; charset=utf-8' \
65
+ --header 'requestId: <uuid>' \
66
+ --header 'Accept: application/xml' \
67
+ --data '<Request>
68
+ <CancelServicesRequest>
69
+ <AgentID>YOUR_AGENT_ID</AgentID>
70
+ <Password>YOUR_AGENT_PASSWORD</Password>
71
+ <BookingId>14226</BookingId>
72
+ <ReturnBooking>Y</ReturnBooking>
73
+ </CancelServicesRequest>
74
+ </Request>'
75
+ ```
76
+
77
+ ### HostConnect XML Response (example)
78
+
79
+ ```xml
80
+ <?xml version="1.0" encoding="utf-8"?>
81
+ <!DOCTYPE Reply SYSTEM "hostConnect_5_05_010.dtd">
82
+ <Reply>
83
+ <CancelServicesReply>
84
+ <BookingId>14226</BookingId>
85
+ <Ref>A2IN111975</Ref>
86
+ <ServiceStatuses>
87
+ <ServiceStatus>
88
+ <ServiceLineId>61738</ServiceLineId>
89
+ <Date>2026-07-03</Date>
90
+ <SequenceNumber>10</SequenceNumber>
91
+ <Status>XX</Status>
92
+ </ServiceStatus>
93
+ </ServiceStatuses>
94
+ </CancelServicesReply>
95
+ </Reply>
96
+ ```
97
+
98
+ ### Plugin Return Shape
99
+
100
+ The plugin returns both:
101
+
102
+ - `cancelServicesReply`: raw parsed `CancelServicesReply` object
103
+ - `cancellation`: normalized object
104
+ - `id`: from `BookingId`
105
+ - `status`: aggregate over all `ServiceStatus.Status` values:
106
+ - same status on all lines => that status (e.g. `XX`)
107
+ - mixed line statuses => `MIXED`
package/index.js CHANGED
@@ -10,6 +10,7 @@ const Normalizer = require('./normalizer');
10
10
 
11
11
  const { searchAvailabilityForItinerary } = require('./availability/itinerary-availability');
12
12
  const { addServiceToItinerary } = require('./itinerary-add-service');
13
+ const { cancelBooking } = require('./itinerary-cancel-booking');
13
14
  const { searchProductsForItinerary } = require('./itinerary-products-search');
14
15
  const { searchItineraries } = require('./itinerary-search');
15
16
  const { hostConnectXmlOptions } = require('./utils');
@@ -679,6 +680,19 @@ class BuyerPlugin {
679
680
  });
680
681
  }
681
682
 
683
+ async cancelBooking({
684
+ axios,
685
+ token,
686
+ payload,
687
+ }) {
688
+ return cancelBooking({
689
+ axios,
690
+ token,
691
+ payload,
692
+ callTourplan: this.callTourplan.bind(this),
693
+ });
694
+ }
695
+
682
696
  async searchItineraries({
683
697
  axios,
684
698
  typeDefsAndQueries,
package/index.test.js CHANGED
@@ -132,6 +132,149 @@ describe('search tests', () => {
132
132
  }
133
133
  });
134
134
  });
135
+ describe('cancelBooking', () => {
136
+ it('cancels a booking by id', async () => {
137
+ mockCallTourplan.mockImplementationOnce(async () => ({
138
+ CancelServicesReply: {
139
+ BookingId: '316559',
140
+ Ref: 'A2IN111975',
141
+ ServiceStatuses: {
142
+ ServiceStatus: {
143
+ ServiceLineId: '61738',
144
+ Date: '2026-07-03',
145
+ SequenceNumber: '10',
146
+ Status: 'XX',
147
+ },
148
+ },
149
+ },
150
+ }));
151
+ const retVal = await app.cancelBooking({
152
+ axios,
153
+ token,
154
+ payload: {
155
+ id: '316559',
156
+ status: 'Cancelled',
157
+ clicked: 1719571452,
158
+ },
159
+ });
160
+ expect(mockCallTourplan).toHaveBeenNthCalledWith(1, expect.objectContaining({
161
+ model: {
162
+ CancelServicesRequest: expect.objectContaining({
163
+ BookingId: '316559',
164
+ ReturnBooking: 'Y',
165
+ }),
166
+ },
167
+ }));
168
+ expect(retVal).toEqual({
169
+ cancelServicesReply: {
170
+ BookingId: '316559',
171
+ Ref: 'A2IN111975',
172
+ ServiceStatuses: {
173
+ ServiceStatus: {
174
+ ServiceLineId: '61738',
175
+ Date: '2026-07-03',
176
+ SequenceNumber: '10',
177
+ Status: 'XX',
178
+ },
179
+ },
180
+ },
181
+ cancellation: {
182
+ id: '316559',
183
+ status: 'XX',
184
+ },
185
+ });
186
+ });
187
+
188
+ it('cancels a booking by ref', async () => {
189
+ mockCallTourplan.mockImplementationOnce(async () => ({
190
+ CancelServicesReply: {
191
+ BookingId: '316559',
192
+ ServiceStatuses: {
193
+ ServiceStatus: [{
194
+ ServiceLineId: '61738',
195
+ Status: 'XX',
196
+ }],
197
+ },
198
+ },
199
+ }));
200
+ const retVal = await app.cancelBooking({
201
+ axios,
202
+ token,
203
+ payload: {
204
+ ref: 'ALFI393706',
205
+ status: 'Cancelled',
206
+ clicked: 1719571452,
207
+ },
208
+ });
209
+ expect(mockCallTourplan).toHaveBeenNthCalledWith(1, expect.objectContaining({
210
+ model: {
211
+ CancelServicesRequest: expect.objectContaining({
212
+ Ref: 'ALFI393706',
213
+ ReturnBooking: 'Y',
214
+ }),
215
+ },
216
+ }));
217
+ expect(retVal).toEqual({
218
+ cancelServicesReply: {
219
+ BookingId: '316559',
220
+ ServiceStatuses: {
221
+ ServiceStatus: [{
222
+ ServiceLineId: '61738',
223
+ Status: 'XX',
224
+ }],
225
+ },
226
+ },
227
+ cancellation: {
228
+ id: '316559',
229
+ status: 'XX',
230
+ },
231
+ });
232
+ });
233
+
234
+ it('returns MIXED status when service statuses differ', async () => {
235
+ mockCallTourplan.mockImplementationOnce(async () => ({
236
+ CancelServicesReply: {
237
+ BookingId: '316559',
238
+ ServiceStatuses: {
239
+ ServiceStatus: [{
240
+ ServiceLineId: '61738',
241
+ Status: 'XX',
242
+ }, {
243
+ ServiceLineId: '61739',
244
+ Status: 'RQ',
245
+ }],
246
+ },
247
+ },
248
+ }));
249
+ const retVal = await app.cancelBooking({
250
+ axios,
251
+ token,
252
+ payload: {
253
+ bookingId: '316559',
254
+ status: 'Cancelled',
255
+ clicked: 1719571452,
256
+ },
257
+ });
258
+ expect(retVal).toEqual({
259
+ cancelServicesReply: {
260
+ BookingId: '316559',
261
+ ServiceStatuses: {
262
+ ServiceStatus: [{
263
+ ServiceLineId: '61738',
264
+ Status: 'XX',
265
+ }, {
266
+ ServiceLineId: '61739',
267
+ Status: 'RQ',
268
+ }],
269
+ },
270
+ },
271
+ cancellation: {
272
+ id: '316559',
273
+ status: 'MIXED',
274
+ },
275
+ });
276
+ });
277
+ });
135
278
  describe('template tests', () => {
136
279
  let template;
137
280
  it('get the template', async () => {
@@ -0,0 +1,110 @@
1
+ const assert = require('assert');
2
+ const R = require('ramda');
3
+ const {
4
+ hostConnectXmlOptions,
5
+ escapeInvalidXmlChars,
6
+ } = require('./utils');
7
+
8
+ const getIdentifier = payload => {
9
+ const bookingId = payload.bookingId || payload.id;
10
+ if (bookingId) {
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
+ };
25
+
26
+ const extractCancellationReply = replyObj => (
27
+ R.path(['CancelServicesReply'], replyObj)
28
+ || {}
29
+ );
30
+
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
+ const normalizeCancellationResponse = (replyObj, payload, identifier) => {
49
+ const cancellationReply = extractCancellationReply(replyObj);
50
+ const aggregateServiceStatus = getAggregateServiceStatus(cancellationReply);
51
+ return {
52
+ cancelServicesReply: cancellationReply,
53
+ cancellation: {
54
+ id: R.path(['BookingId'], cancellationReply) || identifier.value,
55
+ status: aggregateServiceStatus
56
+ || R.path(['BookingStatus'], cancellationReply)
57
+ || R.path(['Status'], cancellationReply)
58
+ || payload.status
59
+ || 'Cancelled',
60
+ },
61
+ };
62
+ };
63
+
64
+ /**
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
72
+ */
73
+ const cancelBooking = async ({
74
+ axios,
75
+ token: {
76
+ hostConnectEndpoint,
77
+ hostConnectAgentID,
78
+ hostConnectAgentPassword,
79
+ },
80
+ payload = {},
81
+ callTourplan,
82
+ }) => {
83
+ assert(hostConnectEndpoint, 'Must provide token.hostConnectEndpoint for booking cancellation');
84
+ assert(hostConnectAgentID, 'Must provide token.hostConnectAgentID for booking cancellation');
85
+ assert(hostConnectAgentPassword, 'Must provide token.hostConnectAgentPassword for booking cancellation');
86
+ const identifier = getIdentifier(payload);
87
+ assert(identifier, 'Must provide booking id or reference for cancellation');
88
+
89
+ const baseRequest = {
90
+ AgentID: hostConnectAgentID,
91
+ Password: hostConnectAgentPassword,
92
+ [identifier.field]: identifier.value,
93
+ };
94
+ const replyObj = await callTourplan({
95
+ model: {
96
+ CancelServicesRequest: {
97
+ ...baseRequest,
98
+ ReturnBooking: 'Y',
99
+ },
100
+ },
101
+ endpoint: hostConnectEndpoint,
102
+ axios,
103
+ xmlOptions: hostConnectXmlOptions,
104
+ });
105
+ return normalizeCancellationResponse(replyObj, payload, identifier);
106
+ };
107
+
108
+ module.exports = {
109
+ cancelBooking,
110
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2-tourplan",
3
- "version": "1.0.122",
3
+ "version": "1.0.123",
4
4
  "description": "Tourplan's TI2 Plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {