@ticketboothapp/booking 1.2.166 → 1.2.168

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.
Files changed (65) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +15 -1
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +274 -94
  4. package/src/components/booking/AdminChangeCheckoutPanel.tsx +49 -13
  5. package/src/components/booking/AdminChangePricingPanel.tsx +400 -134
  6. package/src/components/booking/AdminChangePromoEditor.tsx +97 -0
  7. package/src/components/booking/AdminChangeReceiptComparison.tsx +172 -147
  8. package/src/components/booking/ChangeBookingFlow.tsx +6 -3
  9. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +7 -2
  10. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +6 -0
  11. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +80 -0
  12. package/src/components/booking/NewBookingFlow.tsx +14 -2
  13. package/src/components/booking/PickupLocationSelector.module.css +22 -0
  14. package/src/components/booking/PickupLocationSelector.tsx +3 -3
  15. package/src/components/booking/PriceBreakdown.tsx +66 -15
  16. package/src/components/booking/PriceSummary.tsx +16 -0
  17. package/src/components/booking/PrivateShuttleBookingFlow.tsx +52 -14
  18. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +95 -54
  19. package/src/components/booking/admin-adjustment-components.ts +44 -0
  20. package/src/components/booking/admin-adjustment-tax-behavior.ts +50 -0
  21. package/src/components/booking/admin-change-flow-state-helpers.ts +10 -0
  22. package/src/components/booking/admin-change-provider-payload.ts +12 -1
  23. package/src/components/booking/admin-change-quote-request-key.ts +41 -1
  24. package/src/components/booking/admin-change-receipt-lines.ts +49 -0
  25. package/src/components/booking/admin-change-v2-quote-request.ts +109 -0
  26. package/src/components/booking/admin-refund-decision-context.ts +122 -0
  27. package/src/components/booking/admin-refund-disposition.ts +71 -2
  28. package/src/components/booking/availability-date-selection.ts +16 -0
  29. package/src/components/booking/booking-flow-types.ts +4 -0
  30. package/src/components/booking/booking-flow.css +5 -0
  31. package/src/components/booking/change-booking-error-message.ts +137 -0
  32. package/src/components/booking/change-booking-flow-helpers.ts +77 -2
  33. package/src/components/booking/change-booking-quote-guards.ts +4 -1
  34. package/src/components/booking/change-booking-quote-state.ts +44 -1
  35. package/src/components/booking/incompatible-add-on-selections.ts +19 -0
  36. package/src/components/booking/private-shuttle-availability.ts +14 -0
  37. package/src/components/booking/private-shuttle-cancellation-policy.ts +9 -0
  38. package/src/components/booking/private-shuttle-checkout-controller.ts +5 -0
  39. package/src/components/booking/private-shuttle-provider-change-runner.ts +21 -0
  40. package/src/components/booking/private-shuttle-reservation-runner.ts +5 -3
  41. package/src/components/booking/provider-dashboard-change-booking.ts +18 -2
  42. package/src/components/booking/use-standard-booking-availability.ts +5 -2
  43. package/src/components/booking/useAdminChangeCheckoutController.ts +56 -1
  44. package/src/components/booking/useAdminChangeProductReset.ts +6 -0
  45. package/src/components/booking/useAdminChangeProtectedPricing.ts +4 -0
  46. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +8 -6
  47. package/src/components/booking/useAdminChangeQuotePreview.ts +146 -61
  48. package/src/components/booking/useAdminCustomReceiptLines.ts +171 -10
  49. package/src/components/booking/useAdminProviderPricingAdjustments.ts +50 -19
  50. package/src/components/booking/useBookingAvailabilityAddOns.ts +26 -4
  51. package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
  52. package/src/components/booking/useChangeBookingAddOnFloorController.ts +15 -9
  53. package/src/components/booking/useChangeBookingAutoSelections.ts +60 -17
  54. package/src/components/booking/useChangeBookingInitialHydration.ts +10 -7
  55. package/src/components/booking/useChangeBookingQuotePreview.ts +3 -1
  56. package/src/components/booking/useChangeBookingSelectionDetails.ts +23 -17
  57. package/src/components/booking/useStandardBookingAutoSelections.ts +55 -7
  58. package/src/lib/booking/booking-cutoffs.ts +22 -0
  59. package/src/lib/booking/change-booking-server-preview.ts +92 -5
  60. package/src/lib/booking/change-flow-pricing.ts +12 -0
  61. package/src/lib/booking/i18n/messages/en.json +1 -0
  62. package/src/lib/booking/i18n/messages/fr.json +1 -0
  63. package/src/lib/booking-api.ts +76 -1
  64. package/test/change-booking-helpers.test.ts +1319 -7
  65. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +0 -178
@@ -11,18 +11,59 @@ import {
11
11
  } from '../src/lib/booking/change-flow-pricing';
12
12
  import {
13
13
  mapAdminChangeBookingQuoteV2Data,
14
+ type Availability,
14
15
  type ChangeBookingQuoteResponse,
15
16
  } from '../src/lib/booking-api';
16
- import { buildAdminChangeQuoteRequestKey } from '../src/components/booking/admin-change-quote-request-key';
17
+ import {
18
+ filterAvailabilitiesAfterCurrentTime,
19
+ filterAvailabilitiesAfterPublicBookingCutoff,
20
+ } from '../src/lib/booking/booking-cutoffs';
21
+ import {
22
+ findFirstDateWithBookableAvailability,
23
+ selectedDateHasVisibleAvailability,
24
+ } from '../src/components/booking/availability-date-selection';
25
+ import {
26
+ buildChangeBookingServerPreview,
27
+ labelAdminAmendmentPriceSummaryLines,
28
+ mapQuoteLineItemsToPriceSummaryLines,
29
+ } from '../src/lib/booking/change-booking-server-preview';
30
+ import {
31
+ buildAdminChangeQuoteRequestKey,
32
+ canRequestAdminChangeQuote,
33
+ canReuseCompletedAdminChangeQuote,
34
+ } from '../src/components/booking/admin-change-quote-request-key';
35
+ import {
36
+ authoritativeItineraryDisplayFromChangeQuote,
37
+ sliceAndMergeChangeQuoteForUi,
38
+ } from '../src/components/booking/change-booking-quote-state';
39
+ import { selectableAdminAdjustmentComponentsFromQuote } from '../src/components/booking/admin-adjustment-components';
40
+ import {
41
+ adminAdjustmentTaxBehaviorOptions,
42
+ normalizeAdminAdjustmentScopeForSign,
43
+ normalizeAdminAdjustmentTaxBehavior,
44
+ } from '../src/components/booking/admin-adjustment-tax-behavior';
45
+ import {
46
+ buildAdminChangeV2QuoteRequest,
47
+ quoteAdminChangeV2ForUi,
48
+ } from '../src/components/booking/admin-change-v2-quote-request';
17
49
  import {
18
50
  noRefundRemovedValue,
19
51
  normalizeReturnPriceTreatment,
52
+ adminRemovalOperationLabel,
53
+ refundTreatmentDisplayValue,
20
54
  refundDecisionsComplete,
21
55
  releaseRefundDispositionOptions,
22
56
  returnPriceTreatmentOptions,
23
57
  } from '../src/components/booking/admin-refund-disposition';
58
+ import {
59
+ defaultKeepOriginalReturnPriceDispositions,
60
+ defaultPendingRefundDispositions,
61
+ mergeAdminRefundDecisionContext,
62
+ selectRefundDispositionsForOperations,
63
+ } from '../src/components/booking/admin-refund-decision-context';
24
64
  import {
25
65
  applyResourceAdjustments,
66
+ privateShuttleInitialDateKey,
26
67
  privateShuttlePriceChanged,
27
68
  resolveHydratedPrivateShuttleAvailability,
28
69
  } from '../src/components/booking/private-shuttle-availability';
@@ -33,12 +74,36 @@ import {
33
74
  reservationHoldSecondsRemaining,
34
75
  } from '../src/components/booking/reservation-hold';
35
76
  import { buildAdminChangeProviderPayload } from '../src/components/booking/admin-change-provider-payload';
36
- import { buildPublicChangeAmountDueSummary } from '../src/components/booking/change-booking-flow-helpers';
77
+ import {
78
+ buildPublicChangeAmountDueSummary,
79
+ deriveCompatibleInitialBookingQuantities,
80
+ isProductFamilyReceiptConversion,
81
+ resolveChangeBookingCancellationPolicyId,
82
+ resolveInitialAvailabilityFromBooking,
83
+ } from '../src/components/booking/change-booking-flow-helpers';
84
+ import { collapseAdminPromoLines } from '../src/components/booking/admin-change-receipt-lines';
37
85
  import {
38
86
  buildAdminChangePaymentChoiceData,
39
87
  buildAdminChangePayNowCheckoutModalData,
40
88
  shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose,
41
89
  } from '../src/components/booking/admin-change-payment-choice-runner';
90
+ import { shouldLockExistingAddOnQuantities } from '../src/components/booking/admin-change-flow-state-helpers';
91
+ import { haveAddOnSelectionsChanged } from '../src/components/booking/useChangeBookingSelectionDetails';
92
+ import {
93
+ incompatibleAddOnSelections,
94
+ removeAddOnSelections,
95
+ } from '../src/components/booking/incompatible-add-on-selections';
96
+ import {
97
+ friendlyChangeBookingError,
98
+ incompatibleAddOnChangeError,
99
+ } from '../src/components/booking/change-booking-error-message';
100
+ import { applyPrivateShuttleProviderChange } from '../src/components/booking/private-shuttle-provider-change-runner';
101
+ import { resolvePrivateShuttleCancellationPolicyId } from '../src/components/booking/private-shuttle-cancellation-policy';
102
+ import { hasEffectiveAdminChangeSelection } from '../src/components/booking/useAdminProviderPricingAdjustments';
103
+ import {
104
+ lineFromActiveAdjustment,
105
+ reverseAdjustmentIdsForAdminCustomLines,
106
+ } from '../src/components/booking/useAdminCustomReceiptLines';
42
107
 
43
108
  function quote(overrides: Partial<ChangeBookingQuoteResponse>): ChangeBookingQuoteResponse {
44
109
  return {
@@ -57,16 +122,334 @@ function quoteSlice(overrides: Partial<ChangeQuoteUiSlice>): ChangeQuoteUiSlice
57
122
  };
58
123
  }
59
124
 
60
- function test(name: string, fn: () => void): void {
125
+ function test(name: string, fn: () => void | Promise<void>): void {
61
126
  try {
62
- fn();
63
- console.log(`ok - ${name}`);
127
+ const result = fn();
128
+ if (result instanceof Promise) {
129
+ void result.then(
130
+ () => console.log(`ok - ${name}`),
131
+ (error) => {
132
+ console.error(`not ok - ${name}`);
133
+ throw error;
134
+ },
135
+ );
136
+ } else {
137
+ console.log(`ok - ${name}`);
138
+ }
64
139
  } catch (error) {
65
140
  console.error(`not ok - ${name}`);
66
141
  throw error;
67
142
  }
68
143
  }
69
144
 
145
+ function calendarAvailability(dateTime: string, vacancies: number): Availability {
146
+ return { dateTime, vacancies, currency: 'CAD' };
147
+ }
148
+
149
+ test('complete percentage-only admin adjustment requests a server quote', () => {
150
+ assert.equal(
151
+ hasEffectiveAdminChangeSelection({
152
+ hasChangeSelection: false,
153
+ providerPricingOverrideCount: 0,
154
+ providerHasAdditionalAdjustments: false,
155
+ isProviderDashboardChange: false,
156
+ editableSummaryPreSubtotalDelta: 0,
157
+ adminStructuredAdjustmentCount: 1,
158
+ }),
159
+ true,
160
+ );
161
+ });
162
+
163
+ test('reversing an existing admin adjustment is an effective change', () => {
164
+ assert.equal(
165
+ hasEffectiveAdminChangeSelection({
166
+ hasChangeSelection: false,
167
+ providerPricingOverrideCount: 0,
168
+ providerHasAdditionalAdjustments: false,
169
+ isProviderDashboardChange: true,
170
+ editableSummaryPreSubtotalDelta: 0,
171
+ adminStructuredAdjustmentCount: 0,
172
+ adminReverseAdjustmentCount: 1,
173
+ }),
174
+ true,
175
+ );
176
+ });
177
+
178
+ test('existing admin adjustment hydrates for immutable reverse-and-replace editing', () => {
179
+ const line = lineFromActiveAdjustment({
180
+ adjustmentId: 'admin-rcpt-1',
181
+ operationId: 'op-1',
182
+ mode: 'FIXED_AMOUNT',
183
+ scope: 'CURRENT_ACTIVE_BOOKING',
184
+ basisCents: 0,
185
+ amountCents: 2_000,
186
+ taxDeltaCents: 170,
187
+ affectedComponentIds: [],
188
+ taxBehavior: 'TAXABLE',
189
+ reasonCode: 'STAFF_ADJUSTMENT',
190
+ reason: 'Local test service fee',
191
+ createdAt: '2026-07-27T00:00:00Z',
192
+ }, 'admin-edit-session-1');
193
+
194
+ assert.equal(line.label, 'Local test service fee');
195
+ assert.equal(line.amountInput, '20.00');
196
+ assert.equal(line.sourceAdjustmentId, 'admin-rcpt-1');
197
+ assert.equal(line.replacementAdjustmentId, 'admin-edit-session-1');
198
+ assert.deepEqual(reverseAdjustmentIdsForAdminCustomLines([line]), []);
199
+ assert.deepEqual(
200
+ reverseAdjustmentIdsForAdminCustomLines([{ ...line, isDirty: true }]),
201
+ ['admin-rcpt-1'],
202
+ );
203
+ assert.deepEqual(reverseAdjustmentIdsForAdminCustomLines([], ['admin-rcpt-1']), ['admin-rcpt-1']);
204
+ });
205
+
206
+ test('private resource quantity is not carried into a regular-tour conversion', () => {
207
+ const quantities = deriveCompatibleInitialBookingQuantities(
208
+ [{ category: 'RESOURCE', count: 1 }],
209
+ {
210
+ dateTime: '2026-08-10T09:00:00-06:00',
211
+ vacancies: 10,
212
+ currency: 'CAD',
213
+ productType: 'STANDARD',
214
+ rates: [
215
+ { rateId: 'adult', category: 'ADULT', available: 10 },
216
+ { rateId: 'child', category: 'CHILD', available: 10 },
217
+ ],
218
+ },
219
+ );
220
+
221
+ assert.deepEqual(quantities, {});
222
+ });
223
+
224
+ test('compatible original ticket quantities are preserved for a regular-tour change', () => {
225
+ const quantities = deriveCompatibleInitialBookingQuantities(
226
+ [
227
+ { category: 'ADULT', count: 2 },
228
+ { category: 'CHILD', count: 1 },
229
+ ],
230
+ {
231
+ dateTime: '2026-08-10T09:00:00-06:00',
232
+ vacancies: 10,
233
+ currency: 'CAD',
234
+ productType: 'STANDARD',
235
+ rates: [
236
+ { rateId: 'adult', category: 'ADULT', available: 10 },
237
+ { rateId: 'child', category: 'CHILD', available: 10 },
238
+ ],
239
+ },
240
+ );
241
+
242
+ assert.deepEqual(quantities, { ADULT: 2, CHILD: 1 });
243
+ });
244
+
245
+ test('family conversion selects the target cancellation policy default', () => {
246
+ const policies = [
247
+ { id: 'standard', label: 'Standard', feeByCurrency: { CAD: 0 } },
248
+ { id: 'flexible', label: 'Flexible', feeByCurrency: { CAD: 25 } },
249
+ ];
250
+
251
+ assert.equal(
252
+ resolveChangeBookingCancellationPolicyId('private_shuttle', policies, 'CAD', true),
253
+ 'standard',
254
+ );
255
+ assert.equal(
256
+ resolveChangeBookingCancellationPolicyId('flexible', policies, 'CAD', false),
257
+ 'flexible',
258
+ );
259
+ });
260
+
261
+ test('resource and passenger receipts are recognized as a product-family conversion', () => {
262
+ assert.equal(
263
+ isProductFamilyReceiptConversion(
264
+ [{ kind: 'ticket', category: 'RESOURCE', qty: 1, itemTotal: 2199.99 }],
265
+ [{ kind: 'ticket', category: 'ADULT', qty: 1, itemTotal: 120.99 }],
266
+ ),
267
+ true,
268
+ );
269
+ });
270
+
271
+ test('server preview exposes the resulting receipt separately from ledger allocation lines', () => {
272
+ const preview = buildChangeBookingServerPreview(
273
+ quote({
274
+ pricingQuote: {
275
+ currency: 'CAD',
276
+ payableTotal: 148.62,
277
+ netSubtotalBeforeTax: 136.98,
278
+ taxAmount: 11.64,
279
+ amountPreviouslyPaid: 836.85,
280
+ amountToCharge: 0,
281
+ refundCandidate: 688.23,
282
+ lines: [
283
+ {
284
+ type: 'BOOKING_CHANGE',
285
+ label: 'Balance reduction · RESOURCE',
286
+ amount: -1670.89,
287
+ },
288
+ ],
289
+ amendment: {
290
+ updatedReceipt: {
291
+ total: 148.62,
292
+ lineItems: [
293
+ { type: 'TICKET', label: 'ADULT', amount: 120.99, quantity: 1 },
294
+ { type: 'FEE', label: 'Moraine Lake Road Access Fee', amount: 15.99 },
295
+ { type: 'TAX', label: 'Taxes and fees', amount: 11.64 },
296
+ ],
297
+ },
298
+ },
299
+ },
300
+ refundCandidate: 688.23,
301
+ }),
302
+ { total: 148.62, subtotal: 136.98, tax: 11.64 },
303
+ 'CAD',
304
+ );
305
+
306
+ assert.deepEqual(
307
+ preview?.resultingReceiptLines?.map((line) =>
308
+ line.kind === 'ticket' ? line.category : line.label,
309
+ ),
310
+ ['ADULT', 'Moraine Lake Road Access Fee', 'Taxes and fees'],
311
+ );
312
+ assert.equal(preview?.amountDue, -688.23);
313
+ });
314
+
315
+ test('existing add-on quantities are locked only for customer self-service changes', () => {
316
+ assert.equal(
317
+ shouldLockExistingAddOnQuantities({ isCustomerSelfServeChange: true, isAdmin: false }),
318
+ true,
319
+ );
320
+ assert.equal(
321
+ shouldLockExistingAddOnQuantities({ isCustomerSelfServeChange: true, isAdmin: true }),
322
+ false,
323
+ );
324
+ assert.equal(
325
+ shouldLockExistingAddOnQuantities({ isCustomerSelfServeChange: false, isAdmin: true }),
326
+ false,
327
+ );
328
+ });
329
+
330
+ test('removing every existing add-on is detected as a booking change', () => {
331
+ const initialAddOnQtyByKey = new Map([
332
+ ['addon_el_lunch::blackforest_ham_sandwich_gatorade', 1],
333
+ ]);
334
+
335
+ assert.equal(haveAddOnSelectionsChanged(initialAddOnQtyByKey, []), true);
336
+ });
337
+
338
+ test('admin amendment display combines one promo allocation across component rows', () => {
339
+ const lines = collapseAdminPromoLines([
340
+ { kind: 'ticket', category: 'ADULT', qty: 1, itemTotal: 170.49 },
341
+ { kind: 'line', label: 'Moraine Lake Road Access Fee', amount: 15.99, type: 'FEE' },
342
+ { kind: 'line', label: 'Discount · Moraine Lake Road Access Fee', amount: -2.4, type: 'DISCOUNT' },
343
+ { kind: 'line', label: 'Discount · ADULT', amount: -25.57, type: 'DISCOUNT' },
344
+ ], 'banffblog15');
345
+
346
+ assert.deepEqual(lines, [
347
+ { kind: 'ticket', category: 'ADULT', qty: 1, itemTotal: 170.49 },
348
+ { kind: 'line', label: 'Moraine Lake Road Access Fee', amount: 15.99, type: 'FEE' },
349
+ { kind: 'line', label: 'Promo: BANFFBLOG15', amount: -27.97, type: 'PROMO_CODE' },
350
+ ]);
351
+ });
352
+
353
+ test('admin amendment display leaves unrelated manual discounts separate', () => {
354
+ const lines = collapseAdminPromoLines([
355
+ { kind: 'line', label: 'Promo retained booking value', amount: -10, type: 'DISCOUNT' },
356
+ { kind: 'line', label: 'Manager courtesy', amount: -5, type: 'DISCOUNT' },
357
+ { kind: 'line', label: 'Discount · ADULT', amount: -20, type: 'PROMO_CODE' },
358
+ ], 'SAVE20');
359
+
360
+ assert.deepEqual(lines, [
361
+ { kind: 'line', label: 'Promo: SAVE20', amount: -30, type: 'PROMO_CODE' },
362
+ { kind: 'line', label: 'Manager courtesy', amount: -5, type: 'DISCOUNT' },
363
+ ]);
364
+ });
365
+
366
+ test('admin availability filtering removes only starts that have passed', () => {
367
+ const now = new Date('2026-07-21T17:00:00-06:00');
368
+ const rows = [
369
+ calendarAvailability('2026-07-21T16:59:00-06:00', 4),
370
+ calendarAvailability('2026-07-21T17:01:00-06:00', 4),
371
+ calendarAvailability('2026-07-22T08:00:00-06:00', 4),
372
+ ];
373
+
374
+ assert.deepEqual(
375
+ filterAvailabilitiesAfterCurrentTime(rows, now).map((row) => row.dateTime),
376
+ ['2026-07-21T17:01:00-06:00', '2026-07-22T08:00:00-06:00'],
377
+ );
378
+ });
379
+
380
+ test('public availability filtering keeps its stricter advance cutoff', () => {
381
+ const now = new Date('2026-07-21T17:00:00-06:00');
382
+ const rows = [
383
+ calendarAvailability('2026-07-21T17:30:00-06:00', 4),
384
+ calendarAvailability('2026-07-21T18:30:00-06:00', 4),
385
+ ];
386
+
387
+ assert.deepEqual(
388
+ filterAvailabilitiesAfterPublicBookingCutoff(rows, now, 60).map((row) => row.dateTime),
389
+ ['2026-07-21T18:30:00-06:00'],
390
+ );
391
+ });
392
+
393
+ test('calendar auto-selection advances when the selected day has no visible starts', () => {
394
+ const rowsByDate: Record<string, Availability[]> = {
395
+ '2026-07-22': [calendarAvailability('2026-07-22T08:00:00-06:00', 0)],
396
+ '2026-07-23': [calendarAvailability('2026-07-23T08:00:00-06:00', 3)],
397
+ };
398
+ const getRowsForDate = (date: string) => rowsByDate[date] ?? [];
399
+
400
+ assert.equal(selectedDateHasVisibleAvailability('2026-07-21', getRowsForDate), false);
401
+ assert.equal(
402
+ findFirstDateWithBookableAvailability(
403
+ ['2026-07-22', '2026-07-23'],
404
+ getRowsForDate,
405
+ (availability) => availability.vacancies > 0,
406
+ ),
407
+ '2026-07-23',
408
+ );
409
+ });
410
+
411
+ test('product change falls back to the most-popular available pickup', () => {
412
+ const times: Availability[] = [
413
+ {
414
+ availabilityId: 'availability_725',
415
+ productOptionId: 'option_725',
416
+ dateTime: '2026-07-30T07:25:00-06:00',
417
+ vacancies: 10,
418
+ currency: 'CAD',
419
+ },
420
+ {
421
+ availabilityId: 'availability_900',
422
+ productOptionId: 'option_900',
423
+ dateTime: '2026-07-30T09:00:00-06:00',
424
+ vacancies: 25,
425
+ currency: 'CAD',
426
+ },
427
+ {
428
+ availabilityId: 'availability_1300',
429
+ productOptionId: 'option_1300',
430
+ dateTime: '2026-07-30T13:00:00-06:00',
431
+ vacancies: 13,
432
+ currency: 'CAD',
433
+ },
434
+ ];
435
+
436
+ const result = resolveInitialAvailabilityFromBooking(
437
+ times,
438
+ new Date('2026-07-30T08:00:00-06:00'),
439
+ 'America/Edmonton',
440
+ 'old_product_availability',
441
+ null,
442
+ [{ category: 'ADULT', count: 1 }],
443
+ null,
444
+ 'CAD',
445
+ 235.98,
446
+ 'option_900',
447
+ );
448
+
449
+ assert.equal(result.defer, false);
450
+ assert.equal(result.selection?.availabilityId, 'availability_900');
451
+ });
452
+
70
453
  test('admin payment choice countdown expires exactly at the reservation deadline', () => {
71
454
  const expiration = '2026-07-16T15:46:44+00:00';
72
455
  assert.equal(
@@ -206,6 +589,122 @@ test('admin quote response display state cannot invalidate its request identity'
206
589
  assert.equal(keyAfterResponse, keyBeforeResponse);
207
590
  });
208
591
 
592
+ test('admin quote dedupe refetches unchanged inputs after the published quote is cleared', () => {
593
+ const inputKey = 'same-admin-change-inputs';
594
+ assert.equal(canReuseCompletedAdminChangeQuote(inputKey, inputKey, true), true);
595
+ assert.equal(canReuseCompletedAdminChangeQuote(inputKey, inputKey, false), false);
596
+ assert.equal(canReuseCompletedAdminChangeQuote(inputKey, 'different-inputs', true), false);
597
+ });
598
+
599
+ test('admin quote gate allows adjustment-only changes', () => {
600
+ assert.equal(canRequestAdminChangeQuote({
601
+ isCustomerSelfServeChange: true,
602
+ hasEffectiveChangeSelection: true,
603
+ hasSelectedAvailability: true,
604
+ bookingReference: 'DIFG66OF',
605
+ lastName: 'Tauro',
606
+ missingRequiredReturnSelection: false,
607
+ }), true);
608
+
609
+ assert.equal(canRequestAdminChangeQuote({
610
+ isCustomerSelfServeChange: true,
611
+ hasEffectiveChangeSelection: false,
612
+ hasSelectedAvailability: true,
613
+ bookingReference: 'DIFG66OF',
614
+ lastName: 'Tauro',
615
+ missingRequiredReturnSelection: false,
616
+ }), false);
617
+ });
618
+
619
+ test('admin preview and checkout share the Pricing V2 request shape', () => {
620
+ const request = buildAdminChangeV2QuoteRequest({
621
+ bookingReference: ' VFSCAEKQ ',
622
+ lastName: ' Tauro ',
623
+ parentProductId: 'product_1',
624
+ optionId: 'option_1',
625
+ selectedAvailability: {
626
+ availabilityId: 'availability_1',
627
+ productId: 'option_1',
628
+ dateTime: '2026-07-28T08:35:00-06:00',
629
+ vacancies: 10,
630
+ } as Parameters<typeof buildAdminChangeV2QuoteRequest>[0]['selectedAvailability'],
631
+ pickupLocationId: 'pickup_1',
632
+ returnAvailabilityId: null,
633
+ bookingItems: [{ category: 'ADULT', count: 3 }],
634
+ addOnSelections: [],
635
+ cancellationPolicyId: 'policy_1',
636
+ promoCode: ' banffblog15 ',
637
+ promoApplicationScope: 'ENTIRE_BOOKING',
638
+ structuredAdjustments: [{ type: 'CUSTOM', amount: 5 }],
639
+ includeSelectableAdjustmentComponents: true,
640
+ refundDispositionsByOperationId: { remove_1: 'PENDING_REFUND' },
641
+ previousPassengerCount: 2,
642
+ previousAvailabilityId: 'availability_old',
643
+ previousReturnAvailabilityId: null,
644
+ });
645
+
646
+ assert.equal(request.bookingReference, 'VFSCAEKQ');
647
+ assert.equal(request.lastName, 'Tauro');
648
+ assert.equal(request.newProductId, 'option_1');
649
+ assert.deepEqual(request.newPassengerCounts, [{ category: 'ADULT', count: 3 }]);
650
+ assert.equal(request.promoCode, 'banffblog15');
651
+ assert.equal(request.promoApplicationScope, 'ENTIRE_BOOKING');
652
+ assert.deepEqual(request.refundDispositionsByOperationId, { remove_1: 'PENDING_REFUND' });
653
+ assert.equal(request.includeSelectableAdjustmentComponents, true);
654
+ assert.deepEqual(request.capacitySeatCredit, {
655
+ enabled: true,
656
+ previousPassengerCount: 2,
657
+ previousAvailabilityId: 'availability_old',
658
+ previousReturnAvailabilityId: null,
659
+ });
660
+ assert.equal('clientProposedTotal' in request, false);
661
+ });
662
+
663
+ test('admin checkout maps the fresh Pricing V2 quote id instead of a legacy response', async () => {
664
+ const request = buildAdminChangeV2QuoteRequest({
665
+ bookingReference: 'VFSCAEKQ',
666
+ lastName: 'Tauro',
667
+ parentProductId: 'product_1',
668
+ optionId: 'option_1',
669
+ selectedAvailability: {
670
+ availabilityId: 'availability_1',
671
+ productId: 'option_1',
672
+ dateTime: '2026-07-28T08:35:00-06:00',
673
+ vacancies: 10,
674
+ } as Parameters<typeof buildAdminChangeV2QuoteRequest>[0]['selectedAvailability'],
675
+ pickupLocationId: 'pickup_1',
676
+ returnAvailabilityId: null,
677
+ bookingItems: [{ category: 'ADULT', count: 3 }],
678
+ addOnSelections: [],
679
+ cancellationPolicyId: 'policy_1',
680
+ structuredAdjustments: [],
681
+ refundDispositionsByOperationId: {},
682
+ previousPassengerCount: 2,
683
+ previousAvailabilityId: 'availability_old',
684
+ previousReturnAvailabilityId: null,
685
+ });
686
+ const mergedQuote = await quoteAdminChangeV2ForUi(
687
+ request,
688
+ { total: 768.11, subtotal: 707.94, tax: 60.17 },
689
+ 'CAD',
690
+ async () => quote({
691
+ pricingQuote: {
692
+ quoteId: 'quote_v2_checkout',
693
+ currency: 'CAD',
694
+ payableTotal: 768.11,
695
+ amountToCharge: 256.03,
696
+ balanceDelta: 256.03,
697
+ lines: [],
698
+ },
699
+ balanceDeltaMajorUnits: 256.03,
700
+ }),
701
+ );
702
+
703
+ assert.equal(mergedQuote.quoteId, 'quote_v2_checkout');
704
+ assert.equal(mergedQuote.priceDiff, 256.03);
705
+ assert.equal(mergedQuote.quotedTotal, 768.11);
706
+ });
707
+
209
708
  test('admin no-charge provider payload carries the selected pickup itinerary', () => {
210
709
  const itineraryDisplay: NonNullable<
211
710
  Parameters<typeof buildAdminChangeProviderPayload>[0]['itineraryDisplay']
@@ -235,6 +734,7 @@ test('admin no-charge provider payload carries the selected pickup itinerary', (
235
734
  cancellationPolicyId: 'standard',
236
735
  initialCancellationPolicyId: 'standard',
237
736
  appliedPromoCode: null,
737
+ promoApplicationScope: 'CHANGE_ONLY',
238
738
  newTotalAmount: 202.87,
239
739
  providerPricingOverrides: [],
240
740
  mergedProviderAdditionalAdjustments: [],
@@ -255,6 +755,7 @@ test('admin no-charge provider payload carries the selected pickup itinerary', (
255
755
  assert.deepEqual(payload?.addOnSelections, []);
256
756
  assert.equal(payload?.pricingV2QuoteId, 'quote_reviewed');
257
757
  assert.equal(payload?.pricingV2QuotedTotal, 202.87);
758
+ assert.equal(payload?.promoApplicationScope, null);
258
759
  });
259
760
 
260
761
  test('public paid change preview shows amendment-only charges before combined tax', () => {
@@ -309,8 +810,22 @@ test('admin removal quote preserves explicit release refund decisions for the pr
309
810
  quantityDelta: -1,
310
811
  };
311
812
  const mapped = mapAdminChangeBookingQuoteV2Data({
312
- oldReceipt: { currency: 'CAD', grossSubtotal: 357.46, taxAmount: 30.38, payableTotal: 387.84 },
313
- newQuote: { currency: 'CAD', payableTotal: 193.92, balanceDelta: 0, amountToCharge: 0, refundCandidate: 0 },
813
+ oldReceipt: {
814
+ currency: 'CAD',
815
+ grossSubtotal: 357.46,
816
+ paymentCreditTotal: 50,
817
+ taxAmount: 30.38,
818
+ payableTotal: 387.84,
819
+ },
820
+ newQuote: {
821
+ currency: 'CAD',
822
+ payableTotal: 193.92,
823
+ paymentCreditTotal: 25,
824
+ amountPreviouslyPaid: 148.62,
825
+ balanceDelta: 0,
826
+ amountToCharge: 0,
827
+ refundCandidate: 0,
828
+ },
314
829
  canApply: false,
315
830
  reasonIfBlocked: 'admin_refund_disposition_required',
316
831
  refundDecisionOperations: [operation],
@@ -330,6 +845,10 @@ test('admin removal quote preserves explicit release refund decisions for the pr
330
845
  );
331
846
 
332
847
  assert.deepEqual(slice.refundDecisionOperations, [operation]);
848
+ assert.equal(slice.amountPreviouslyPaid, 148.62);
849
+ assert.equal(slice.currentPaymentCreditTotal, 50);
850
+ assert.equal(slice.projectedPaymentCreditTotal, 25);
851
+ assert.equal(slice.refundCandidate, 0);
333
852
  assert.equal(slice.removedValueByOperationId?.[operation.operationId], 193.92);
334
853
  assert.deepEqual(
335
854
  releaseRefundDispositionOptions(
@@ -349,6 +868,300 @@ test('admin removal quote preserves explicit release refund decisions for the pr
349
868
  );
350
869
  });
351
870
 
871
+ test('admin refund decisions remain stable when successive quotes return only unresolved operations', () => {
872
+ const removeReturn = {
873
+ operationId: 'op_remove_return',
874
+ type: 'REMOVE_RETURN',
875
+ componentKey: 'return:rt_morning',
876
+ quantityDelta: -1,
877
+ };
878
+ const removeAdult = {
879
+ operationId: 'op_remove_adult',
880
+ type: 'REMOVE_QUANTITY',
881
+ componentKey: 'ticket:ADULT',
882
+ quantityDelta: -1,
883
+ };
884
+ const allowed = ['PENDING_REFUND', 'NO_REFUND'] as const;
885
+
886
+ const initial = mergeAdminRefundDecisionContext({
887
+ current: null,
888
+ selectionKey: 'selection-a',
889
+ operations: [removeReturn, removeAdult],
890
+ allowedByOperationId: {
891
+ [removeReturn.operationId]: [...allowed],
892
+ [removeAdult.operationId]: [...allowed],
893
+ },
894
+ removedValueByOperationId: {
895
+ [removeReturn.operationId]: 42.32,
896
+ [removeAdult.operationId]: 220.83,
897
+ },
898
+ });
899
+ const afterReturnDecision = mergeAdminRefundDecisionContext({
900
+ current: initial,
901
+ selectionKey: 'selection-a',
902
+ // The server now returns only the still-unanswered quantity removal.
903
+ operations: [removeAdult],
904
+ allowedByOperationId: { [removeAdult.operationId]: [...allowed] },
905
+ removedValueByOperationId: { [removeAdult.operationId]: 220.83 },
906
+ });
907
+
908
+ assert.deepEqual(
909
+ afterReturnDecision?.operations.map((operation) => operation.operationId),
910
+ [removeReturn.operationId, removeAdult.operationId],
911
+ );
912
+ assert.deepEqual(
913
+ selectRefundDispositionsForOperations(afterReturnDecision, [], {
914
+ [removeReturn.operationId]: 'PENDING_REFUND',
915
+ }),
916
+ { [removeReturn.operationId]: 'PENDING_REFUND' },
917
+ );
918
+
919
+ const complete = mergeAdminRefundDecisionContext({
920
+ current: afterReturnDecision,
921
+ selectionKey: 'selection-a',
922
+ operations: [],
923
+ allowedByOperationId: {},
924
+ removedValueByOperationId: {},
925
+ });
926
+ assert.deepEqual(
927
+ selectRefundDispositionsForOperations(complete, [], {
928
+ [removeReturn.operationId]: 'PENDING_REFUND',
929
+ [removeAdult.operationId]: 'NO_REFUND',
930
+ }),
931
+ {
932
+ [removeReturn.operationId]: 'PENDING_REFUND',
933
+ [removeAdult.operationId]: 'NO_REFUND',
934
+ },
935
+ );
936
+
937
+ const nextSelection = mergeAdminRefundDecisionContext({
938
+ current: complete,
939
+ selectionKey: 'selection-b',
940
+ operations: [removeAdult],
941
+ allowedByOperationId: { [removeAdult.operationId]: [...allowed] },
942
+ removedValueByOperationId: { [removeAdult.operationId]: 220.83 },
943
+ });
944
+ assert.deepEqual(
945
+ nextSelection?.operations.map((operation) => operation.operationId),
946
+ [removeAdult.operationId],
947
+ );
948
+ assert.deepEqual(
949
+ selectRefundDispositionsForOperations(nextSelection, [], {
950
+ [removeReturn.operationId]: 'PENDING_REFUND',
951
+ [removeAdult.operationId]: 'NO_REFUND',
952
+ }),
953
+ { [removeAdult.operationId]: 'NO_REFUND' },
954
+ );
955
+ });
956
+
957
+ test('admin removal decisions default every required operation to pending refund', () => {
958
+ const removeReturn = {
959
+ operationId: 'op_remove_return',
960
+ type: 'REMOVE_RETURN',
961
+ componentKey: 'return:rt_morning',
962
+ quantityDelta: -1,
963
+ };
964
+ const removeAdult = {
965
+ operationId: 'op_remove_adult',
966
+ type: 'REMOVE_QUANTITY',
967
+ componentKey: 'ticket:ADULT',
968
+ quantityDelta: -1,
969
+ };
970
+
971
+ assert.deepEqual(
972
+ defaultPendingRefundDispositions(
973
+ [removeReturn, removeAdult],
974
+ {
975
+ unrelated_operation: 'NO_REFUND',
976
+ [removeAdult.operationId]: 'NO_REFUND',
977
+ },
978
+ ),
979
+ {
980
+ unrelated_operation: 'NO_REFUND',
981
+ [removeReturn.operationId]: 'PENDING_REFUND',
982
+ [removeAdult.operationId]: 'PENDING_REFUND',
983
+ },
984
+ );
985
+ });
986
+
987
+ test('admin return-price decisions always default to keeping the original paid floor', () => {
988
+ const returnOperation = {
989
+ operationId: 'op_change_return',
990
+ type: 'CHANGE_RETURN',
991
+ componentKey: 'return:rt_early',
992
+ quantityDelta: 0,
993
+ };
994
+
995
+ assert.deepEqual(
996
+ defaultKeepOriginalReturnPriceDispositions(
997
+ [returnOperation],
998
+ {
999
+ op_remove_adult: 'PENDING_REFUND',
1000
+ [returnOperation.operationId]: 'NO_REFUND',
1001
+ },
1002
+ ),
1003
+ { op_remove_adult: 'PENDING_REFUND' },
1004
+ );
1005
+ });
1006
+
1007
+ test('admin move-service quote labels only its ticket price difference', () => {
1008
+ const moveOperation = {
1009
+ operationId: 'op_move_service',
1010
+ type: 'MOVE_SERVICE',
1011
+ componentKey: 'service',
1012
+ changedFields: ['availabilityId', 'dateTime'],
1013
+ };
1014
+ const addOperation = {
1015
+ operationId: 'op_add_adult',
1016
+ type: 'ADD_QUANTITY',
1017
+ componentKey: 'ticket:ADULT',
1018
+ quantityDelta: 1,
1019
+ };
1020
+ const mapped = mapAdminChangeBookingQuoteV2Data({
1021
+ oldReceipt: {
1022
+ currency: 'CAD',
1023
+ grossSubtotal: 247.96,
1024
+ taxAmount: 21.08,
1025
+ payableTotal: 269.04,
1026
+ lines: [
1027
+ {
1028
+ type: 'TICKET',
1029
+ label: 'ADULT',
1030
+ quantity: 2,
1031
+ amount: 215.98,
1032
+ billable: true,
1033
+ priceBasis: {
1034
+ baseUnitAmount: 139.99,
1035
+ finalUnitAmount: 107.99,
1036
+ currency: 'CAD',
1037
+ adjustments: [
1038
+ {
1039
+ type: 'DYNAMIC_PRICING',
1040
+ id: 'rule_early_booking',
1041
+ name: 'Early booking discount',
1042
+ amount: -32,
1043
+ adjustmentType: 'FIXED',
1044
+ adjustmentValue: 32,
1045
+ },
1046
+ ],
1047
+ },
1048
+ },
1049
+ ],
1050
+ },
1051
+ newQuote: {
1052
+ currency: 'CAD',
1053
+ payableTotal: 455.64,
1054
+ balanceDelta: 186.60,
1055
+ amendment: { operations: [moveOperation, addOperation] },
1056
+ lines: [
1057
+ {
1058
+ type: 'TICKET',
1059
+ label: 'ADULT',
1060
+ quantity: 2,
1061
+ amount: 48,
1062
+ billable: true,
1063
+ metadata: { category: 'ADULT', operationId: moveOperation.operationId },
1064
+ priceBasis: {
1065
+ baseUnitAmount: 149.99,
1066
+ finalUnitAmount: 131.99,
1067
+ currency: 'CAD',
1068
+ adjustments: [
1069
+ {
1070
+ type: 'dynamic',
1071
+ id: 'rule_new_date',
1072
+ name: 'New-date adjustment',
1073
+ amount: -18,
1074
+ adjustmentType: 'fixed',
1075
+ adjustmentValue: -18,
1076
+ },
1077
+ ],
1078
+ },
1079
+ },
1080
+ {
1081
+ type: 'TICKET',
1082
+ label: 'ADULT',
1083
+ quantity: 1,
1084
+ amount: 124,
1085
+ billable: true,
1086
+ metadata: { category: 'ADULT', operationId: addOperation.operationId },
1087
+ },
1088
+ ],
1089
+ },
1090
+ canApply: true,
1091
+ });
1092
+ assert.deepEqual(mapped.originalReceipt?.lineItems?.[0]?.priceBasis, {
1093
+ baseUnitAmount: 139.99,
1094
+ finalUnitAmount: 107.99,
1095
+ currency: 'CAD',
1096
+ adjustments: [
1097
+ {
1098
+ type: 'DYNAMIC_PRICING',
1099
+ id: 'rule_early_booking',
1100
+ name: 'Early booking discount',
1101
+ amount: -32,
1102
+ adjustmentType: 'FIXED',
1103
+ adjustmentValue: 32,
1104
+ },
1105
+ ],
1106
+ });
1107
+ const existingPurchaseLines = mapQuoteLineItemsToPriceSummaryLines(
1108
+ mapped.originalReceipt?.lineItems,
1109
+ );
1110
+ assert.deepEqual(
1111
+ existingPurchaseLines[0]?.kind === 'ticket'
1112
+ ? existingPurchaseLines[0].breakdown?.lineItems
1113
+ : null,
1114
+ [
1115
+ { type: 'base', name: 'Base price', amountInDisplayCurrency: 139.99 },
1116
+ {
1117
+ type: 'adjustment',
1118
+ id: 'rule_early_booking',
1119
+ name: 'Early booking discount',
1120
+ amountInDisplayCurrency: -32,
1121
+ adjustmentType: 'FIXED',
1122
+ adjustmentValue: 32,
1123
+ sourceType: 'dynamic',
1124
+ },
1125
+ ],
1126
+ );
1127
+ const preview = buildChangeBookingServerPreview(
1128
+ mapped,
1129
+ { total: 455.64, subtotal: 419.94, tax: 35.70 },
1130
+ 'CAD',
1131
+ );
1132
+ const lines = preview?.priceSummaryLines ?? [];
1133
+
1134
+ assert.equal(lines[0]?.kind, 'ticket');
1135
+ assert.equal(lines[0]?.kind === 'ticket' ? lines[0].category : null, 'ADULT — new date price difference');
1136
+ assert.deepEqual(
1137
+ lines[0]?.kind === 'ticket' ? lines[0].unitPriceComparison : null,
1138
+ {
1139
+ previousLabel: 'Existing adult price',
1140
+ updatedLabel: 'New-date adult price',
1141
+ differenceLabel: 'Difference per adult',
1142
+ previousUnitAmount: 107.99,
1143
+ updatedUnitAmount: 131.99,
1144
+ differenceUnitAmount: 24,
1145
+ },
1146
+ );
1147
+ assert.deepEqual(
1148
+ lines[0]?.kind === 'ticket' ? lines[0].breakdown?.lineItems : null,
1149
+ [
1150
+ { type: 'base', name: 'Base price', amountInDisplayCurrency: 149.99 },
1151
+ {
1152
+ type: 'adjustment',
1153
+ id: 'rule_new_date',
1154
+ name: 'New-date adjustment',
1155
+ amountInDisplayCurrency: -18,
1156
+ adjustmentType: 'fixed',
1157
+ adjustmentValue: -18,
1158
+ sourceType: 'dynamic',
1159
+ },
1160
+ ],
1161
+ );
1162
+ assert.equal(lines[1]?.kind === 'ticket' ? lines[1].category : null, 'ADULT');
1163
+ });
1164
+
352
1165
  test('admin return price treatment defaults to keep floor and maps optional reprice choices', () => {
353
1166
  const operation = {
354
1167
  operationId: 'op_change_return',
@@ -379,6 +1192,46 @@ test('admin return price treatment defaults to keep floor and maps optional repr
379
1192
  );
380
1193
  });
381
1194
 
1195
+ test('admin cross-tour move labels its ticket delta as a tour price difference', () => {
1196
+ const [line] = labelAdminAmendmentPriceSummaryLines(
1197
+ [{
1198
+ kind: 'ticket',
1199
+ operationId: 'op_move_tour',
1200
+ category: 'Refund · ADULT',
1201
+ qty: 1,
1202
+ itemTotal: -99,
1203
+ }],
1204
+ [{
1205
+ operationId: 'op_move_tour',
1206
+ type: 'MOVE_SERVICE',
1207
+ componentKey: 'service',
1208
+ changedFields: ['productId', 'availabilityId', 'dateTime'],
1209
+ }],
1210
+ [{
1211
+ kind: 'ticket',
1212
+ category: 'ADULT',
1213
+ qty: 1,
1214
+ itemTotal: 219.99,
1215
+ }],
1216
+ );
1217
+
1218
+ assert.equal(
1219
+ line?.kind === 'ticket' ? line.category : null,
1220
+ 'Refund · ADULT — tour price difference',
1221
+ );
1222
+ assert.deepEqual(
1223
+ line?.kind === 'ticket' ? line.unitPriceComparison : null,
1224
+ {
1225
+ previousLabel: 'Existing adult price',
1226
+ updatedLabel: 'New-tour adult price',
1227
+ differenceLabel: 'Difference per adult',
1228
+ previousUnitAmount: 219.99,
1229
+ updatedUnitAmount: 120.99,
1230
+ differenceUnitAmount: -99,
1231
+ },
1232
+ );
1233
+ });
1234
+
382
1235
  test('unsupported immediate refund cannot become a return price treatment', () => {
383
1236
  assert.equal(normalizeReturnPriceTreatment(undefined), 'KEEP_FLOOR');
384
1237
  assert.equal(normalizeReturnPriceTreatment('REFUND_TO_ORIGINAL_PAYMENT'), 'KEEP_FLOOR');
@@ -443,9 +1296,20 @@ test('admin cancellation policy and structured adjustments are authoritative quo
443
1296
  ...base,
444
1297
  structuredAdjustments: [{ adjustmentId: 'adj_1', mode: 'FIXED_AMOUNT', fixedAmount: 10 }],
445
1298
  });
1299
+ const wholeBookingPromo = buildAdminChangeQuoteRequestKey({
1300
+ ...base,
1301
+ promoCode: 'BANFFBLOG15',
1302
+ promoApplicationScope: 'ENTIRE_BOOKING',
1303
+ });
1304
+ const changeOnlyPromo = buildAdminChangeQuoteRequestKey({
1305
+ ...base,
1306
+ promoCode: 'BANFFBLOG15',
1307
+ promoApplicationScope: 'CHANGE_ONLY',
1308
+ });
446
1309
 
447
1310
  assert.notEqual(original, policyChanged);
448
1311
  assert.notEqual(original, adjustmentChanged);
1312
+ assert.notEqual(changeOnlyPromo, wholeBookingPromo);
449
1313
  });
450
1314
 
451
1315
  test('private shuttle pricing replaces the selected calendar summary with hydrated rate details', () => {
@@ -489,6 +1353,142 @@ test('private shuttle pricing replaces the selected calendar summary with hydrat
489
1353
  assert.equal(applyResourceAdjustments(1699.99, resolved, 'CAD'), 2099.99);
490
1354
  });
491
1355
 
1356
+ test('private conversion keeps the existing booking day in the company timezone', () => {
1357
+ assert.equal(
1358
+ privateShuttleInitialDateKey('2026-07-28T01:05:00Z', 'America/Edmonton'),
1359
+ '2026-07-27',
1360
+ );
1361
+ assert.equal(
1362
+ privateShuttleInitialDateKey('2026-07-27', 'America/Edmonton'),
1363
+ '2026-07-27',
1364
+ );
1365
+ });
1366
+
1367
+ test('private conversion replaces an incompatible source cancellation policy with the target default', () => {
1368
+ assert.equal(
1369
+ resolvePrivateShuttleCancellationPolicyId('standard_flexible', ['private_shuttle']),
1370
+ 'private_shuttle',
1371
+ );
1372
+ assert.equal(
1373
+ resolvePrivateShuttleCancellationPolicyId('private_shuttle', ['private_shuttle']),
1374
+ 'private_shuttle',
1375
+ );
1376
+ });
1377
+
1378
+ test('admin standard-to-private conversion sends complete private target identity and state', async () => {
1379
+ let captured: Parameters<typeof applyPrivateShuttleProviderChange>[0] extends {
1380
+ onChangeBooking: (data: infer T) => Promise<void>;
1381
+ }
1382
+ ? T
1383
+ : never;
1384
+
1385
+ await applyPrivateShuttleProviderChange({
1386
+ onChangeBooking: async (data) => {
1387
+ captured = data;
1388
+ },
1389
+ parentProductId: 'p_private',
1390
+ selectedOption: 'po_private',
1391
+ selectedAvailabilityId: 'a_private',
1392
+ selectedDate: '2026-07-29',
1393
+ selectedStartTime: '09:15',
1394
+ billableResourceCount: 2,
1395
+ pickupLocationId: 'pickup_hotel',
1396
+ selectedPickupLocationName: 'Banff Hotel',
1397
+ initialBooking: {
1398
+ bookingReference: 'bookRef_TEST1234',
1399
+ productId: 'po_standard',
1400
+ availabilityId: 'a_standard',
1401
+ dateTime: '2026-07-29T08:00:00-06:00',
1402
+ bookingItems: [{ category: 'ADULT', count: 5 }],
1403
+ returnAvailabilityId: 'ret_standard',
1404
+ },
1405
+ passengerCount: 5,
1406
+ childSafetySeatsCount: 1,
1407
+ foodRestrictions: 'No nuts',
1408
+ addOnSelections: [],
1409
+ cancellationPolicyId: 'flexible',
1410
+ activePromoCode: null,
1411
+ totalPrice: 1200,
1412
+ additionalHoursCount: 1,
1413
+ draftItineraryDestinations: ['moraine_lake'],
1414
+ draftItineraryPlanningNotes: 'Photo stop requested',
1415
+ itineraryDisplayItems: [
1416
+ { time: '9:15 AM', label: 'Banff Hotel' },
1417
+ { time: null, label: 'Moraine Lake', isProposedStops: true },
1418
+ { time: '3:15 PM', label: 'Banff Hotel' },
1419
+ ],
1420
+ isAdmin: true,
1421
+ providerChangeAuthoritativeReceipt: undefined,
1422
+ });
1423
+
1424
+ assert.equal(captured!.parentProductId, 'p_private');
1425
+ assert.equal(captured!.productId, 'po_private');
1426
+ assert.equal(captured!.availabilityId, 'a_private');
1427
+ assert.deepEqual(captured!.bookingItems, [{ category: 'RESOURCE', count: 2 }]);
1428
+ assert.equal(captured!.returnAvailabilityId, null);
1429
+ assert.equal(captured!.passengerCount, 5);
1430
+ assert.equal(captured!.startTime, '2026-07-29T09:15:00-06:00');
1431
+ assert.deepEqual(captured!.draftItinerary, {
1432
+ destinations: ['moraine_lake'],
1433
+ planningNotes: 'Photo stop requested',
1434
+ });
1435
+ assert.deepEqual(captured!.itineraryDisplay?.map((step) => step.stepType), [
1436
+ 'pickup',
1437
+ 'draft',
1438
+ 'drop_off',
1439
+ ]);
1440
+ });
1441
+
1442
+ test('private conversion stores the first actual location as pickup when proposed stops come first', async () => {
1443
+ let captured: Parameters<typeof applyPrivateShuttleProviderChange>[0] extends {
1444
+ onChangeBooking: (data: infer T) => Promise<void>;
1445
+ }
1446
+ ? T
1447
+ : never;
1448
+
1449
+ await applyPrivateShuttleProviderChange({
1450
+ onChangeBooking: async (data) => {
1451
+ captured = data;
1452
+ },
1453
+ parentProductId: 'p_private',
1454
+ selectedOption: 'po_private',
1455
+ selectedAvailabilityId: 'a_private',
1456
+ selectedDate: '2026-07-29',
1457
+ selectedStartTime: '07:00',
1458
+ billableResourceCount: 1,
1459
+ pickupLocationId: 'pickup_hotel',
1460
+ selectedPickupLocationName: 'Banff Hotel',
1461
+ initialBooking: {
1462
+ bookingReference: 'bookRef_TEST1234',
1463
+ productId: 'po_standard',
1464
+ availabilityId: 'a_standard',
1465
+ dateTime: '2026-07-29T08:00:00-06:00',
1466
+ bookingItems: [{ category: 'ADULT', count: 1 }],
1467
+ },
1468
+ passengerCount: 1,
1469
+ childSafetySeatsCount: 0,
1470
+ foodRestrictions: '',
1471
+ addOnSelections: [],
1472
+ cancellationPolicyId: 'private_shuttle',
1473
+ activePromoCode: null,
1474
+ totalPrice: 1200,
1475
+ additionalHoursCount: 0,
1476
+ draftItineraryDestinations: ['moraine_lake', 'lake_louise'],
1477
+ draftItineraryPlanningNotes: '',
1478
+ itineraryDisplayItems: [
1479
+ { time: null, label: 'Moraine Lake, Lake Louise', isProposedStops: true },
1480
+ { time: '7:00 AM', label: 'Banff Hotel' },
1481
+ ],
1482
+ isAdmin: true,
1483
+ providerChangeAuthoritativeReceipt: undefined,
1484
+ });
1485
+
1486
+ assert.deepEqual(captured!.itineraryDisplay, [
1487
+ { stepType: 'draft', time: 'Proposed stops', place: 'Moraine Lake, Lake Louise' },
1488
+ { stepType: 'pickup', time: '7:00 AM', place: 'Banff Hotel' },
1489
+ ]);
1490
+ });
1491
+
492
1492
  test('private shuttle checkout detects a quote total that differs from the displayed total', () => {
493
1493
  assert.equal(privateShuttlePriceChanged(2247, 2681), true);
494
1494
  assert.equal(privateShuttlePriceChanged(2681, 2681.0), false);
@@ -798,3 +1798,315 @@ test('builds paid checkout modal data with trimmed customer and change totals',
798
1798
  differenceTotal: 12,
799
1799
  });
800
1800
  });
1801
+
1802
+ test('keeps an incompatible add-on visible until the admin explicitly removes it', () => {
1803
+ const lunch = {
1804
+ addOnId: 'addon_el_lunch',
1805
+ name: 'Emerald Lake Lunch Package',
1806
+ price: 28,
1807
+ currency: 'CAD',
1808
+ preCheckout: true,
1809
+ postCheckout: false,
1810
+ variantType: 'multi_quantity' as const,
1811
+ variants: [
1812
+ {
1813
+ id: 'blackforest_ham_sandwich_gatorade',
1814
+ label: 'Blackforest Ham Sandwich + Gatorade',
1815
+ priceAdjustment: 0,
1816
+ },
1817
+ ],
1818
+ productOptionIds: ['po_big_three'],
1819
+ };
1820
+ const selections = [
1821
+ {
1822
+ addOnId: lunch.addOnId,
1823
+ variantId: lunch.variants[0].id,
1824
+ quantity: 1,
1825
+ },
1826
+ ];
1827
+
1828
+ assert.deepEqual(incompatibleAddOnSelections(selections, [], false), []);
1829
+ assert.deepEqual(incompatibleAddOnSelections(selections, [lunch], true), []);
1830
+ assert.deepEqual(incompatibleAddOnSelections(selections, [], true), selections);
1831
+ assert.deepEqual(removeAddOnSelections(selections, lunch.addOnId), []);
1832
+ });
1833
+
1834
+ test('removing one incompatible add-on preserves other target selections', () => {
1835
+ const selections = [
1836
+ { addOnId: 'addon_el_lunch', variantId: 'ham', quantity: 1 },
1837
+ { addOnId: 'addon_target_supported', quantity: 2 },
1838
+ ];
1839
+
1840
+ assert.deepEqual(removeAddOnSelections(selections, 'addon_el_lunch'), [
1841
+ { addOnId: 'addon_target_supported', quantity: 2 },
1842
+ ]);
1843
+ });
1844
+
1845
+ test('labels a service move with the source and target tour names', () => {
1846
+ assert.equal(
1847
+ adminRemovalOperationLabel(
1848
+ {
1849
+ operationId: 'op_move',
1850
+ type: 'MOVE_SERVICE',
1851
+ componentKey: 'service',
1852
+ oldIdentity: 'p_big_three:a_source:2026-07-31',
1853
+ targetIdentity: 'po_moraine:a_target:2026-07-31',
1854
+ },
1855
+ {
1856
+ sourceProductName: 'Big 3 Lakes Tour: Moraine, Emerald, Louise',
1857
+ targetProductName: 'Moraine Lake Adventure',
1858
+ },
1859
+ ),
1860
+ 'Change tour: Big 3 Lakes → Moraine Lake Adventure',
1861
+ );
1862
+ });
1863
+
1864
+ test('labels a whole-booking promo adjustment without exposing its internal id', () => {
1865
+ const label = adminRemovalOperationLabel(
1866
+ {
1867
+ operationId: 'op_promo_adjustment',
1868
+ type: 'ADD_MANUAL_ADJUSTMENT',
1869
+ componentKey: 'manual_adjustment:promo_53ef0d9fe839aa8cd88e9b0f',
1870
+ targetIdentity: '-3035',
1871
+ },
1872
+ { promoCode: 'banffblog15' },
1873
+ );
1874
+
1875
+ assert.equal(label, 'Apply promo to entire booking: BANFFBLOG15');
1876
+ assert.doesNotMatch(label, /promo_[a-f0-9]+/i);
1877
+ });
1878
+
1879
+ test('limits adjustment tax treatments by charge or credit direction', () => {
1880
+ assert.deepEqual(
1881
+ adminAdjustmentTaxBehaviorOptions(1).map((option) => option.value),
1882
+ ['TAXABLE', 'NON_TAXABLE'],
1883
+ );
1884
+ assert.deepEqual(
1885
+ adminAdjustmentTaxBehaviorOptions(-1).map((option) => option.value),
1886
+ ['PRE_TAX_DISCOUNT', 'POST_TAX_DISCOUNT'],
1887
+ );
1888
+ assert.equal(normalizeAdminAdjustmentTaxBehavior(-1, 'TAXABLE'), 'PRE_TAX_DISCOUNT');
1889
+ assert.equal(
1890
+ normalizeAdminAdjustmentTaxBehavior(-1, 'POST_TAX_DISCOUNT'),
1891
+ 'POST_TAX_DISCOUNT',
1892
+ );
1893
+ assert.equal(normalizeAdminAdjustmentTaxBehavior(1, 'PRE_TAX_DISCOUNT'), 'TAXABLE');
1894
+ });
1895
+
1896
+ test('switching a new custom line to credit defaults its scope to active tickets', () => {
1897
+ assert.equal(
1898
+ normalizeAdminAdjustmentScopeForSign(-1, 'POSITIVE_AMENDMENT_VALUE'),
1899
+ 'CURRENT_ACTIVE_TICKETS',
1900
+ );
1901
+ assert.equal(
1902
+ normalizeAdminAdjustmentScopeForSign(-1, 'CURRENT_ACTIVE_BOOKING'),
1903
+ 'CURRENT_ACTIVE_BOOKING',
1904
+ );
1905
+ assert.equal(
1906
+ normalizeAdminAdjustmentScopeForSign(1, 'POSITIVE_AMENDMENT_VALUE'),
1907
+ 'POSITIVE_AMENDMENT_VALUE',
1908
+ );
1909
+ });
1910
+
1911
+ test('labels a custom adjustment refund decision with its operator-entered description', () => {
1912
+ const label = adminRemovalOperationLabel(
1913
+ {
1914
+ operationId: 'op_admin_discount',
1915
+ type: 'ADD_MANUAL_ADJUSTMENT',
1916
+ componentKey: 'manual_adjustment:admin-rcpt-1',
1917
+ targetIdentity: '-1000',
1918
+ },
1919
+ { manualAdjustmentLabelsById: { 'admin-rcpt-1': 'Local active-ticket discount' } },
1920
+ );
1921
+
1922
+ assert.equal(label, 'Local active-ticket discount');
1923
+ assert.doesNotMatch(label, /admin-rcpt/i);
1924
+ });
1925
+
1926
+ test('labels an adjustment reversal from server metadata without exposing its internal id', () => {
1927
+ const label = adminRemovalOperationLabel({
1928
+ operationId: 'op_reverse_adjustment',
1929
+ type: 'REVERSE_MANUAL_ADJUSTMENT',
1930
+ componentKey: 'manual_adjustment:admin-edit-ms3ea4jv-1',
1931
+ oldIdentity: '2500',
1932
+ metadata: { displayName: 'Edited service fee' },
1933
+ });
1934
+
1935
+ assert.equal(label, 'Remove adjustment: Edited service fee');
1936
+ assert.doesNotMatch(label, /admin-edit/i);
1937
+ });
1938
+
1939
+ test('shows the true refundable amount after an unpaid balance is reduced', () => {
1940
+ const operation = {
1941
+ operationId: 'op_reverse_adjustment',
1942
+ type: 'REVERSE_MANUAL_ADJUSTMENT',
1943
+ componentKey: 'manual_adjustment:admin-edit-ms3ea4jv-1',
1944
+ };
1945
+
1946
+ assert.equal(
1947
+ refundTreatmentDisplayValue(
1948
+ operation,
1949
+ [operation],
1950
+ { op_reverse_adjustment: 27.13 },
1951
+ 5,
1952
+ ),
1953
+ 5,
1954
+ );
1955
+ assert.equal(
1956
+ noRefundRemovedValue(
1957
+ [operation],
1958
+ { op_reverse_adjustment: 'NO_REFUND' },
1959
+ { op_reverse_adjustment: 27.13 },
1960
+ ),
1961
+ 27.13,
1962
+ );
1963
+ });
1964
+
1965
+ test('translates booking-change reason codes into operator-facing messages', () => {
1966
+ assert.equal(
1967
+ friendlyChangeBookingError('pricing_v2_unsupported_features'),
1968
+ 'Some selected booking options do not work together. Review the highlighted selections before continuing.',
1969
+ );
1970
+ assert.equal(
1971
+ friendlyChangeBookingError('quote_expired'),
1972
+ 'This booking or price changed after the page was opened. Refresh it and try again.',
1973
+ );
1974
+ assert.equal(
1975
+ friendlyChangeBookingError('admin_adjustment_refund_disposition_required:op_adjustment'),
1976
+ 'Choose how to handle the removed adjustment value before continuing.',
1977
+ );
1978
+ assert.equal(
1979
+ friendlyChangeBookingError('promo_change_only_no_eligible_new_value'),
1980
+ 'This promo only applies to new charges. Make a priced change, or choose Entire active booking.',
1981
+ );
1982
+ assert.equal(
1983
+ friendlyChangeBookingError('structured_adjustment_basis_is_zero'),
1984
+ 'This credit needs a value to apply to. Make a priced booking change, or choose All active tickets, Entire active booking, or Selected components.',
1985
+ );
1986
+ assert.equal(
1987
+ friendlyChangeBookingError('Remove the unavailable add-on before recalculating this amendment.'),
1988
+ 'Remove the unavailable add-on before recalculating this amendment.',
1989
+ );
1990
+ assert.doesNotMatch(
1991
+ friendlyChangeBookingError('pricing_v2_future_unsupported_case'),
1992
+ /pricing_v2|_/,
1993
+ );
1994
+ });
1995
+
1996
+ test('names the incompatible add-on and target tour in the operator error', () => {
1997
+ assert.equal(
1998
+ incompatibleAddOnChangeError(
1999
+ ['Emerald Lake Lunch Package'],
2000
+ 'Moraine Lake Adventure',
2001
+ ),
2002
+ "Emerald Lake Lunch Package isn't available with Moraine Lake Adventure. Remove the add-on or choose a different tour.",
2003
+ );
2004
+ assert.equal(
2005
+ incompatibleAddOnChangeError(['Lunch', 'Photo package'], 'Target Tour'),
2006
+ "Lunch and Photo package aren't available with Target Tour. Remove these add-ons or choose a different tour.",
2007
+ );
2008
+ });
2009
+
2010
+ test('uses the authoritative quote itinerary with confirmed dependent sessions', () => {
2011
+ const itinerary = [
2012
+ { stepType: 'pickup', time: '8:05 AM', place: 'Banff Boundary Lodge' },
2013
+ {
2014
+ stepType: 'other',
2015
+ time: '11:00 AM - 11:30 AM',
2016
+ place: 'Friends, Family, & Couples add-on · Photographer: Will Lambert · 30 minutes',
2017
+ },
2018
+ { stepType: 'drop_off', time: '7:00 PM', place: 'Banff Boundary Lodge' },
2019
+ ];
2020
+ const response = quote({
2021
+ pricingQuote: {
2022
+ payableTotal: 212.64,
2023
+ amendment: {
2024
+ targetState: {
2025
+ nonPriceState: {
2026
+ itinerary: JSON.stringify(itinerary),
2027
+ },
2028
+ },
2029
+ },
2030
+ },
2031
+ });
2032
+
2033
+ assert.deepEqual(authoritativeItineraryDisplayFromChangeQuote(response), itinerary);
2034
+ assert.deepEqual(
2035
+ sliceAndMergeChangeQuoteForUi(
2036
+ response,
2037
+ { total: 212.64, subtotal: 195.98, tax: 16.66 },
2038
+ 'CAD',
2039
+ ).mergedQuote.authoritativeItineraryDisplay,
2040
+ itinerary,
2041
+ );
2042
+ });
2043
+
2044
+ test('falls back when the authoritative quote itinerary is malformed', () => {
2045
+ assert.equal(
2046
+ authoritativeItineraryDisplayFromChangeQuote(quote({
2047
+ pricingQuote: {
2048
+ amendment: {
2049
+ targetState: {
2050
+ nonPriceState: {
2051
+ itinerary: '{not-json',
2052
+ },
2053
+ },
2054
+ },
2055
+ },
2056
+ })),
2057
+ null,
2058
+ );
2059
+ });
2060
+
2061
+ test('exposes only authoritative selectable amendment components to admin adjustments', () => {
2062
+ const response = quote({
2063
+ pricingQuote: {
2064
+ amendment: {
2065
+ updatedReceipt: {
2066
+ total: 256.04,
2067
+ lineItems: [
2068
+ {
2069
+ type: 'TICKET',
2070
+ label: 'ADULT',
2071
+ amount: 219.99,
2072
+ identity: { componentId: 'ticket_adult_1' },
2073
+ },
2074
+ {
2075
+ type: 'FEE',
2076
+ label: 'Moraine Lake Road Access Fee',
2077
+ amount: 15.99,
2078
+ identity: { componentId: 'fee_road_access' },
2079
+ },
2080
+ {
2081
+ type: 'TAX',
2082
+ label: 'Taxes and fees',
2083
+ amount: 20.06,
2084
+ identity: { componentId: 'tax_1' },
2085
+ },
2086
+ {
2087
+ type: 'PROMO_CODE',
2088
+ label: 'Promo',
2089
+ amount: -15,
2090
+ identity: { componentId: 'incentive_1' },
2091
+ },
2092
+ ],
2093
+ },
2094
+ },
2095
+ },
2096
+ });
2097
+
2098
+ assert.deepEqual(selectableAdminAdjustmentComponentsFromQuote(response), [
2099
+ {
2100
+ componentId: 'ticket_adult_1',
2101
+ label: 'ADULT',
2102
+ amount: 219.99,
2103
+ type: 'TICKET',
2104
+ },
2105
+ {
2106
+ componentId: 'fee_road_access',
2107
+ label: 'Moraine Lake Road Access Fee',
2108
+ amount: 15.99,
2109
+ type: 'FEE',
2110
+ },
2111
+ ]);
2112
+ });