ti2 1.0.102 → 1.0.106

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/api.yml CHANGED
@@ -451,6 +451,9 @@ components:
451
451
  id:
452
452
  type: string
453
453
  description: a quote Id to make a reservation for
454
+ shellOnly:
455
+ type: boolean
456
+ description: Optional itinerary-assist flag to create/update booking header only without adding a service line
454
457
  settingsBody:
455
458
  type: object
456
459
  required: true
@@ -47,7 +47,7 @@ describe('app', () => {
47
47
  }));
48
48
  expect(appKey).toBeTruthy();
49
49
  });
50
- it('should encode an airbitrary object', async () => {
50
+ it('should encode an arbitrary object', async () => {
51
51
  ({ value: encodedKey } = await doApiPut({
52
52
  url: `/app/encode/${appName}`,
53
53
  token: appKey,
@@ -96,7 +96,7 @@ describe('app', () => {
96
96
  cron: '0 9 * * *',
97
97
  });
98
98
  });
99
- it.todo('if a new version of the plugin has a different set of scheduled tasks, they shoul dbe re-syncjed');
99
+ it.todo('if a new version of the plugin has a different set of scheduled tasks, they should be re-synced');
100
100
  let jobId;
101
101
  it('should be able to run a job', async () => {
102
102
  let status;
@@ -142,4 +142,49 @@ describe('app', () => {
142
142
  ]),
143
143
  );
144
144
  });
145
+
146
+ describe('token and settings merge', () => {
147
+ const hint = apiKey.split('-')[0];
148
+ const settingsOverrideEndpoint = 'https://settings-override.example.com';
149
+
150
+ it('getAppToken returns token with appKey payload (endpoint, apiKey)', async () => {
151
+ // No settings created yet – token is purely from appKey.
152
+ const body = await doApiGet({
153
+ url: `/${appName}/${userId}/${hint}/token`,
154
+ token: appKey,
155
+ payload: {},
156
+ });
157
+ expect(body.token).toEqual(expect.objectContaining({
158
+ endpoint: token.endpoint,
159
+ apiKey: token.apiKey,
160
+ }));
161
+ });
162
+
163
+ it('createAppSettings updates user settings for the app', async () => {
164
+ await doApiPost({
165
+ url: `/settings/${appName}/${userId}`,
166
+ token: adminKey,
167
+ payload: {
168
+ settings: {
169
+ endpoint: settingsOverrideEndpoint,
170
+ onlyInSettings: true,
171
+ },
172
+ },
173
+ });
174
+ });
175
+
176
+ it('getAppToken returns merged token with settings overriding appKey for same keys', async () => {
177
+ const body = await doApiGet({
178
+ url: `/${appName}/${userId}/${hint}/token`,
179
+ token: appKey,
180
+ payload: {},
181
+ });
182
+ // Settings override: endpoint from appKey is overridden by UserIntegrationSettings.settings
183
+ expect(body.token.endpoint).toBe(settingsOverrideEndpoint);
184
+ // Settings add new keys
185
+ expect(body.token.onlyInSettings).toBe(true);
186
+ // appKey-only keys still present
187
+ expect(body.token.apiKey).toBe(token.apiKey);
188
+ });
189
+ });
145
190
  });
@@ -32,6 +32,7 @@ describe('user: bookings controller', () => {
32
32
  let userToken;
33
33
  let appSetup;
34
34
  let createUserToken;
35
+ let sqldb;
35
36
 
36
37
  beforeAll(async () => {
37
38
  ({
@@ -41,6 +42,7 @@ describe('user: bookings controller', () => {
41
42
  plugins,
42
43
  appSetup,
43
44
  createUserToken,
45
+ sqldb,
44
46
  } = await testUtils({
45
47
  plugins: [newApp.name],
46
48
  }));
@@ -168,6 +170,56 @@ describe('user: bookings controller', () => {
168
170
  expect(plugins[0].createBooking.mock.calls[0][0].payload).toEqual(payload);
169
171
  expect(plugins[0].createBooking.mock.calls[0][0].token).toEqual(token);
170
172
  });
173
+ it('should return 422 when stored app token is corrupted on bookings endpoint', async () => {
174
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
175
+ const [originalRows] = await sqldb.query(
176
+ 'SELECT id, appKey FROM UserAppKeys WHERE integrationId = :integrationId AND userId = :userId AND hint = :hint',
177
+ {
178
+ replacements: {
179
+ integrationId: appKey,
180
+ userId,
181
+ hint: 'testingToken',
182
+ },
183
+ },
184
+ );
185
+ await sqldb.query(
186
+ 'UPDATE UserAppKeys SET appKey = :appKey WHERE integrationId = :integrationId AND userId = :userId AND hint = :hint',
187
+ {
188
+ replacements: {
189
+ appKey: 'corrupted-token-without-delimiter',
190
+ integrationId: appKey,
191
+ userId,
192
+ hint: 'testingToken',
193
+ },
194
+ },
195
+ );
196
+ try {
197
+ const returnValue = await doApiPost({
198
+ url: `/bookings/${appKey}/${userId}/testingToken/search`,
199
+ token: userToken,
200
+ payload: {
201
+ bookingId: '',
202
+ supplierBookingId: '',
203
+ name: '',
204
+ },
205
+ expectStatusCode: 422,
206
+ });
207
+ expect(returnValue.message).toBe(
208
+ 'Stored integration token is invalid. Please re-save credentials.',
209
+ );
210
+ } finally {
211
+ await Promise.all(originalRows.map(currentRow => sqldb.query(
212
+ 'UPDATE UserAppKeys SET appKey = :appKey WHERE id = :id',
213
+ {
214
+ replacements: {
215
+ id: currentRow.id,
216
+ appKey: currentRow.appKey,
217
+ },
218
+ },
219
+ )));
220
+ consoleErrorSpy.mockRestore();
221
+ }
222
+ });
171
223
 
172
224
  describe('product search with empty cache', () => {
173
225
  const productSearchCacheKey = hash({
@@ -2,6 +2,7 @@
2
2
 
3
3
  const R = require('ramda');
4
4
  const chance = require('chance').Chance();
5
+ const request = require('supertest');
5
6
  const slugify = require('../../test/slugify');
6
7
 
7
8
  const { env: { adminKey } } = process;
@@ -18,6 +19,8 @@ describe('user', () => {
18
19
  };
19
20
  let doApiGet; let doApiPost; let
20
21
  doApiDelete;
22
+ let app;
23
+ let sqldb;
21
24
  let appKey;
22
25
  const userId = chance.guid();
23
26
  let apiKey = chance.guid();
@@ -29,6 +32,8 @@ describe('user', () => {
29
32
  let plugins;
30
33
  beforeAll(async () => {
31
34
  ({
35
+ app,
36
+ sqldb,
32
37
  doApiDelete,
33
38
  doApiGet,
34
39
  doApiPost,
@@ -162,6 +167,28 @@ describe('user', () => {
162
167
  });
163
168
  expect(returnValue.token).toEqual(token2);
164
169
  });
170
+ it('should return a preflight response for token endpoint', async () => {
171
+ const response = await request(app)
172
+ .options(`/${appName}/${userId}/token`)
173
+ .set('Origin', 'https://tcoutlook.tourconnect.dev')
174
+ .set('Access-Control-Request-Method', 'GET');
175
+ expect([200, 204]).toContain(response.statusCode);
176
+ expect(response.headers['access-control-allow-origin']).toBeTruthy();
177
+ });
178
+ it('should keep returning 404 for a missing token hint', async () => {
179
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
180
+ const missingHint = chance.guid();
181
+ try {
182
+ const returnValue = await doApiGet({
183
+ url: `/${appName}/${userId}/${missingHint}/token`,
184
+ token: userKey,
185
+ expectStatusCode: 404,
186
+ });
187
+ expect(returnValue.message).toContain('User integration is not found');
188
+ } finally {
189
+ consoleErrorSpy.mockRestore();
190
+ }
191
+ });
165
192
  it('should be able to create an app setting', async () => {
166
193
  const returnValue = await doApiPost({
167
194
  url: `/settings/${appName}/${userId}`,
@@ -211,4 +238,92 @@ describe('user', () => {
211
238
  ]),
212
239
  );
213
240
  });
241
+ it('should return 422 when stored app token is corrupted', async () => {
242
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
243
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
244
+ const [originalRows] = await sqldb.query(
245
+ 'SELECT id, appKey FROM UserAppKeys WHERE integrationId = :integrationId AND userId = :userId',
246
+ {
247
+ replacements: {
248
+ integrationId: appName,
249
+ userId,
250
+ },
251
+ },
252
+ );
253
+ await sqldb.query(
254
+ 'UPDATE UserAppKeys SET appKey = :appKey WHERE integrationId = :integrationId AND userId = :userId',
255
+ {
256
+ replacements: {
257
+ appKey: 'corrupted-token-without-delimiter',
258
+ integrationId: appName,
259
+ userId,
260
+ },
261
+ },
262
+ );
263
+ try {
264
+ const returnValue = await doApiGet({
265
+ url: `/${appName}/${userId}/token`,
266
+ token: userKey,
267
+ expectStatusCode: 422,
268
+ });
269
+ expect(returnValue.message).toBe('Stored integration token is invalid. Please re-save credentials.');
270
+ } finally {
271
+ await Promise.all(originalRows.map(currentRow => sqldb.query(
272
+ 'UPDATE UserAppKeys SET appKey = :appKey WHERE id = :id',
273
+ {
274
+ replacements: {
275
+ id: currentRow.id,
276
+ appKey: currentRow.appKey,
277
+ },
278
+ },
279
+ )));
280
+ consoleWarnSpy.mockRestore();
281
+ consoleErrorSpy.mockRestore();
282
+ }
283
+ });
284
+ it('should return 422 when stored app token is corrupted for hinted endpoint', async () => {
285
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
286
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
287
+ const [originalRows] = await sqldb.query(
288
+ 'SELECT id, appKey FROM UserAppKeys WHERE integrationId = :integrationId AND userId = :userId AND hint = :hint',
289
+ {
290
+ replacements: {
291
+ integrationId: appName,
292
+ userId,
293
+ hint: apiKey2.split('-')[1],
294
+ },
295
+ },
296
+ );
297
+ await sqldb.query(
298
+ 'UPDATE UserAppKeys SET appKey = :appKey WHERE integrationId = :integrationId AND userId = :userId AND hint = :hint',
299
+ {
300
+ replacements: {
301
+ appKey: 'corrupted-token-without-delimiter',
302
+ integrationId: appName,
303
+ userId,
304
+ hint: apiKey2.split('-')[1],
305
+ },
306
+ },
307
+ );
308
+ try {
309
+ const returnValue = await doApiGet({
310
+ url: `/${appName}/${userId}/${apiKey2.split('-')[1]}/token`,
311
+ token: userKey,
312
+ expectStatusCode: 422,
313
+ });
314
+ expect(returnValue.message).toBe('Stored integration token is invalid. Please re-save credentials.');
315
+ } finally {
316
+ await Promise.all(originalRows.map(currentRow => sqldb.query(
317
+ 'UPDATE UserAppKeys SET appKey = :appKey WHERE id = :id',
318
+ {
319
+ replacements: {
320
+ id: currentRow.id,
321
+ appKey: currentRow.appKey,
322
+ },
323
+ },
324
+ )));
325
+ consoleWarnSpy.mockRestore();
326
+ consoleErrorSpy.mockRestore();
327
+ }
328
+ });
214
329
  });
@@ -488,20 +488,24 @@ const getAppToken = async (req, res, next) => {
488
488
  hint,
489
489
  },
490
490
  } = req;
491
- // check if the user exists
492
- const userRecord = await sqldb.User.findOne({ where: { userId } });
493
- if (!userRecord) return next({ status: 404, message: 'User does not exists' });
494
- const userAppKey = await sqldb.UserAppKey.findOne({
495
- where: {
496
- userId,
497
- integrationId,
498
- ...(hint ? { hint } : {}),
499
- },
500
- });
501
- if (!userAppKey) {
502
- return next({ status: 404, message: `User integratio is not found for ${integrationId}:${userId}:${hint}` });
491
+ try {
492
+ // check if the user exists
493
+ const userRecord = await sqldb.User.findOne({ where: { userId } });
494
+ if (!userRecord) return next({ status: 404, message: 'User does not exists' });
495
+ const userAppKey = await sqldb.UserAppKey.findOne({
496
+ where: {
497
+ userId,
498
+ integrationId,
499
+ ...(hint ? { hint } : {}),
500
+ },
501
+ });
502
+ if (!userAppKey) {
503
+ return next({ status: 404, message: `User integration is not found for ${integrationId}:${userId}:${hint}` });
504
+ }
505
+ return res.json({ token: await userAppKey.token });
506
+ } catch (err) {
507
+ return next(err);
503
508
  }
504
- return res.json({ token: await userAppKey.token });
505
509
  };
506
510
 
507
511
  const createCronjob = async (req, res, next) => {
package/index.js CHANGED
@@ -48,6 +48,7 @@ const rebuild = fn => obj =>
48
48
  Object.fromEntries(Object.entries(obj).flatMap(([k, v]) => fn(k, v)));
49
49
 
50
50
  const isNumber = value => !Number.isNaN(Number(value));
51
+ const isCorruptedTokenError = err => R.pathOr(false, ['code'], err) === 'CORRUPTED_TOKEN';
51
52
 
52
53
  const axiosSafeRequest = R.pick(['headers', 'method', 'url', 'data']);
53
54
  const axiosSafeResponse = response => {
@@ -163,6 +164,7 @@ module.exports = async ({
163
164
  if (apiDocs) {
164
165
  app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(allSchema));
165
166
  }
167
+ app.options('*', middleware.CORS(), (_req, res) => res.sendStatus(204));
166
168
  app.use(
167
169
  middleware.metadata(),
168
170
  middleware.CORS(),
@@ -255,7 +257,7 @@ module.exports = async ({
255
257
  requestId: req.requestId,
256
258
  err: errMsg,
257
259
  ...(err.request ? { request: axiosSafeRequest(err.request) } : {}),
258
- ...(err.response ? { response: axiosSafeRequest(err.response) } : {}),
260
+ ...(err.response ? { response: axiosSafeResponse(err.response) } : {}),
259
261
  });
260
262
  }
261
263
  return Promise.reject(errMsg);
@@ -373,6 +375,17 @@ module.exports = async ({
373
375
  app.use(middleware.mock());
374
376
  // global error Handling
375
377
  app.use((err, req, res, next) => {
378
+ if (isCorruptedTokenError(err) && !process.env.JEST_WORKER_ID) {
379
+ console.warn('Invalid stored integration token', {
380
+ requestId: req.requestId,
381
+ integrationId: R.path(['context', 'integrationId'], err),
382
+ userId: R.path(['context', 'userId'], err),
383
+ hint: R.path(['context', 'hint'], err),
384
+ errorCode: R.path(['cause', 'code'], err),
385
+ errorName: R.path(['cause', 'name'], err),
386
+ message: R.path(['cause', 'message'], err),
387
+ });
388
+ }
376
389
  if (process.env.CONSOLE_ERRORS || process.env.JEST_WORKER_ID) {
377
390
  console.error(R.pathOr(err, ['response', 'data'], err));
378
391
  }
@@ -0,0 +1,22 @@
1
+ const buildCorruptedTokenError = ({
2
+ integrationId,
3
+ userId,
4
+ hint,
5
+ cause,
6
+ }) => {
7
+ const error = new Error(
8
+ 'Stored integration token is invalid. Please re-save credentials.',
9
+ );
10
+ error.name = 'CorruptedTokenError';
11
+ error.code = 'CORRUPTED_TOKEN';
12
+ error.status = 422;
13
+ error.context = {
14
+ integrationId,
15
+ userId,
16
+ hint,
17
+ };
18
+ error.cause = cause;
19
+ return error;
20
+ };
21
+
22
+ module.exports = buildCorruptedTokenError;
@@ -0,0 +1,35 @@
1
+ /* globals describe it expect */
2
+ const { UserAppKey } = require('../index');
3
+
4
+ describe('UserAppKey.mergeTokenPayload', () => {
5
+ it('uses appKey as base, then settings override same keys, then configuration', () => {
6
+ const appKey = { endpoint: 'https://old.example.com', apiKey: 'secret' };
7
+ const settings = { endpoint: 'https://updated.example.com', onlyInSettings: true };
8
+ const configuration = { foo: 'bar' };
9
+
10
+ const result = UserAppKey.mergeTokenPayload(appKey, settings, configuration);
11
+
12
+ expect(result.endpoint).toBe('https://updated.example.com');
13
+ expect(result.onlyInSettings).toBe(true);
14
+ expect(result.apiKey).toBe('secret');
15
+ expect(result.configuration).toEqual({ foo: 'bar' });
16
+ });
17
+
18
+ it('returns only appKey when settings and configuration are empty', () => {
19
+ const appKey = { access_token: 'x', refresh_token: 'y' };
20
+ const result = UserAppKey.mergeTokenPayload(appKey, {}, null);
21
+ expect(result).toEqual({ access_token: 'x', refresh_token: 'y' });
22
+ });
23
+
24
+ it('strips empty and nil values from appKey', () => {
25
+ const appKey = { a: 1, b: '', c: null, d: [] };
26
+ const result = UserAppKey.mergeTokenPayload(appKey, {}, null);
27
+ expect(result).toEqual({ a: 1 });
28
+ });
29
+
30
+ it('handles null/undefined appKey or settings', () => {
31
+ expect(UserAppKey.mergeTokenPayload(null, { x: 1 }, null)).toEqual({ x: 1 });
32
+ expect(UserAppKey.mergeTokenPayload({ a: 1 }, null, null)).toEqual({ a: 1 });
33
+ expect(UserAppKey.mergeTokenPayload({}, {}, undefined)).toEqual({});
34
+ });
35
+ });
@@ -1,10 +1,10 @@
1
- const jwt = require('jsonwebtoken');
2
1
  const Sequelize = require('sequelize');
3
2
  const R = require('ramda');
3
+ const jwt = require('jsonwebtoken');
4
4
  const { encrypt, decrypt } = require('../lib/security');
5
+ const buildCorruptedTokenError = require('../lib/corrupted-token-error');
5
6
  const db = require('./db');
6
7
 
7
- const { env: { dbCryptoKey } } = process;
8
8
  const removeEmptyAttributes = obj => R.reject(R.anyPass([R.isEmpty, R.isNil]))(obj);
9
9
 
10
10
  const UserAppKey = db.define('UserAppKey', {
@@ -50,18 +50,56 @@ const UserAppKey = db.define('UserAppKey', {
50
50
  token: {
51
51
  type: Sequelize.VIRTUAL,
52
52
  async get() {
53
- if (!this.getDataValue('appKey')) return {};
54
- if (!decrypt(this.getDataValue('appKey'))) return {};
53
+ const encryptedAppKey = this.getDataValue('appKey');
54
+ if (!encryptedAppKey) return {};
55
+
56
+ let appKey;
57
+ if (encryptedAppKey.includes('%')) {
58
+ try {
59
+ const decryptedAppKey = decrypt(encryptedAppKey);
60
+ if (!decryptedAppKey) return {};
61
+ appKey = JSON.parse(decryptedAppKey);
62
+ } catch (err) {
63
+ throw buildCorruptedTokenError({
64
+ integrationId: this.getDataValue('integrationId'),
65
+ userId: this.getDataValue('userId'),
66
+ hint: this.getDataValue('hint'),
67
+ cause: err,
68
+ });
69
+ }
70
+ } else {
71
+ // Legacy unencrypted JWT stored in appKey – decode payload as token object
72
+ try {
73
+ const decodedAppKey = jwt.decode(encryptedAppKey);
74
+ if (!decodedAppKey) {
75
+ throw buildCorruptedTokenError({
76
+ integrationId: this.getDataValue('integrationId'),
77
+ userId: this.getDataValue('userId'),
78
+ hint: this.getDataValue('hint'),
79
+ cause: new Error('Invalid or corrupted JWT'),
80
+ });
81
+ }
82
+ appKey = decodedAppKey;
83
+ } catch (err) {
84
+ if (err.name === 'CorruptedTokenError') throw err;
85
+ throw buildCorruptedTokenError({
86
+ integrationId: this.getDataValue('integrationId'),
87
+ userId: this.getDataValue('userId'),
88
+ hint: this.getDataValue('hint'),
89
+ cause: err,
90
+ });
91
+ }
92
+ }
93
+
55
94
  const sqldb = require('./index');
56
95
  const userIntegrationSettings = await sqldb.UserIntegrationSettings.findOne({
57
96
  where: { userId: this.getDataValue('userId'), integrationId: this.getDataValue('integrationId') },
58
97
  });
59
- const appKey = JSON.parse(decrypt(this.getDataValue('appKey')));
60
- return {
61
- ...R.pathOr({}, ['settings'], userIntegrationSettings),
62
- ...removeEmptyAttributes(appKey),
63
- ...(this.getDataValue('configuration') ? { configuration: this.getDataValue('configuration') } : {}),
64
- };
98
+ return UserAppKey.mergeTokenPayload(
99
+ appKey,
100
+ R.pathOr({}, ['settings'], userIntegrationSettings),
101
+ this.getDataValue('configuration'),
102
+ );
65
103
  },
66
104
  },
67
105
  configuration: {
@@ -69,6 +107,19 @@ const UserAppKey = db.define('UserAppKey', {
69
107
  allowNull: true,
70
108
  },
71
109
  }, {});
110
+
111
+ /** Merge order: appKey base, then settings (so updated UserIntegrationSettings are used), then configuration. */
112
+ UserAppKey.mergeTokenPayload = (appKey, settings, configuration) => {
113
+ const base = appKey ? removeEmptyAttributes(appKey) : {};
114
+ const settingsObj = settings || {};
115
+ const configPart = configuration != null ? { configuration } : {};
116
+ return {
117
+ ...base,
118
+ ...settingsObj,
119
+ ...configPart,
120
+ };
121
+ };
122
+
72
123
  UserAppKey.associate = models => {
73
124
  UserAppKey.belongsTo(models.User, {
74
125
  foreignKey: 'userId',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.102",
3
+ "version": "1.0.106",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/test/plugin.js CHANGED
@@ -718,12 +718,13 @@ class Plugin {
718
718
  * @param {string} args.payload.QB - 'Q' for quote, 'B' for booking
719
719
  * @param {string} args.payload.quoteName - Name of the booking/quote
720
720
  * @param {string} [args.payload.quoteId] - identifier of the booking/quote, if one is provided, we are expecting the service line to be inserted to an existing booking/quote
721
+ * @param {boolean} [args.payload.shellOnly] - Optional flag to create/update booking header only and skip service-line insertion
721
722
  * @param {string} [args.payload.lineId] - identifier of the service line in the booking/quote, if one is provided, we are expecting an existing service line to be updated
722
723
  * @param {string} [args.payload.rateId] - Rate identifier, we are sending 'Default' if no rates are provided by the check availability call
723
- * @param {string} args.payload.optionId - Option identifier of the service line
724
- * @param {string} args.payload.startDate - Start date always in (YYYY-MM-DD) format
724
+ * @param {string} args.payload.optionId - Option identifier of the service line (required unless shellOnly is true)
725
+ * @param {string} args.payload.startDate - Start date always in (YYYY-MM-DD) format (required unless shellOnly is true)
725
726
  * @param {string} [args.payload.reference] - Reference number from external system
726
- * @param {Array<PaxConfig>} args.payload.paxConfigs - Passenger configurations
727
+ * @param {Array<PaxConfig>} args.payload.paxConfigs - Passenger configurations (required unless shellOnly is true)
727
728
  * @param {Array<Object>} [args.payload.extras] - Additional extras
728
729
  * @param {Extra} args.payload.extras.selectedExtra - Selected extra
729
730
  * @param {number} args.payload.extras.quantity - quantity
@@ -267,8 +267,9 @@ No, we don't have a specific test scenarios, but we recommend you to cover the f
267
267
  quoteName: String,
268
268
  rateId: String, // Optional
269
269
  quoteId: String, // Optional
270
- optionId: String,
271
- startDate: String, // YYYY-MM-DD
270
+ shellOnly: Boolean, // Optional. If true, create/update quote header only and skip service insertion
271
+ optionId: String, // Required unless shellOnly is true
272
+ startDate: String, // YYYY-MM-DD, required unless shellOnly is true
272
273
  reference: String, // Optional
273
274
  paxConfigs: [{
274
275
  roomType: String, // Optional
@@ -280,7 +281,7 @@ No, we don't have a specific test scenarios, but we recommend you to cover the f
280
281
  dob: String, // Optional, YYYY-MM-DD
281
282
  personId: String // Optional
282
283
  }]
283
- }],
284
+ }], // Required unless shellOnly is true
284
285
  extras: [{ // Optional
285
286
  selectedExtra: {
286
287
  id: String