@ticketboothapp/booking 1.2.167 → 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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +13 -1
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +180 -65
  4. package/src/components/booking/AdminChangeCheckoutPanel.tsx +43 -13
  5. package/src/components/booking/AdminChangePricingPanel.tsx +400 -134
  6. package/src/components/booking/AdminChangeReceiptComparison.tsx +163 -145
  7. package/src/components/booking/ChangeBookingFlow.tsx +6 -3
  8. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +7 -2
  9. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +6 -0
  10. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +80 -0
  11. package/src/components/booking/PickupLocationSelector.module.css +22 -0
  12. package/src/components/booking/PickupLocationSelector.tsx +3 -3
  13. package/src/components/booking/PriceSummary.tsx +4 -0
  14. package/src/components/booking/PrivateShuttleBookingFlow.tsx +27 -13
  15. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +95 -54
  16. package/src/components/booking/admin-adjustment-components.ts +44 -0
  17. package/src/components/booking/admin-adjustment-tax-behavior.ts +50 -0
  18. package/src/components/booking/admin-change-flow-state-helpers.ts +10 -0
  19. package/src/components/booking/admin-change-provider-payload.ts +3 -0
  20. package/src/components/booking/admin-change-quote-request-key.ts +27 -0
  21. package/src/components/booking/admin-change-v2-quote-request.ts +10 -2
  22. package/src/components/booking/admin-refund-decision-context.ts +37 -0
  23. package/src/components/booking/admin-refund-disposition.ts +71 -2
  24. package/src/components/booking/booking-flow-types.ts +4 -0
  25. package/src/components/booking/booking-flow.css +5 -0
  26. package/src/components/booking/change-booking-error-message.ts +137 -0
  27. package/src/components/booking/change-booking-flow-helpers.ts +77 -2
  28. package/src/components/booking/change-booking-quote-guards.ts +4 -1
  29. package/src/components/booking/change-booking-quote-state.ts +44 -1
  30. package/src/components/booking/incompatible-add-on-selections.ts +19 -0
  31. package/src/components/booking/private-shuttle-availability.ts +14 -0
  32. package/src/components/booking/private-shuttle-cancellation-policy.ts +9 -0
  33. package/src/components/booking/private-shuttle-checkout-controller.ts +5 -0
  34. package/src/components/booking/private-shuttle-provider-change-runner.ts +21 -0
  35. package/src/components/booking/private-shuttle-reservation-runner.ts +5 -3
  36. package/src/components/booking/provider-dashboard-change-booking.ts +16 -1
  37. package/src/components/booking/useAdminChangeCheckoutController.ts +16 -0
  38. package/src/components/booking/useAdminChangeProductReset.ts +6 -0
  39. package/src/components/booking/useAdminChangeProtectedPricing.ts +4 -0
  40. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +6 -4
  41. package/src/components/booking/useAdminChangeQuotePreview.ts +76 -20
  42. package/src/components/booking/useAdminCustomReceiptLines.ts +171 -10
  43. package/src/components/booking/useAdminProviderPricingAdjustments.ts +50 -19
  44. package/src/components/booking/useBookingAvailabilityAddOns.ts +19 -2
  45. package/src/components/booking/useChangeBookingAddOnFloorController.ts +15 -9
  46. package/src/components/booking/useChangeBookingInitialHydration.ts +10 -7
  47. package/src/components/booking/useChangeBookingQuotePreview.ts +3 -1
  48. package/src/components/booking/useChangeBookingSelectionDetails.ts +23 -17
  49. package/src/lib/booking/change-booking-server-preview.ts +26 -9
  50. package/src/lib/booking/change-flow-pricing.ts +12 -0
  51. package/src/lib/booking/i18n/messages/en.json +1 -0
  52. package/src/lib/booking/i18n/messages/fr.json +1 -0
  53. package/src/lib/booking-api.ts +64 -0
  54. package/test/change-booking-helpers.test.ts +853 -3
  55. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +0 -178
@@ -24,12 +24,24 @@ import {
24
24
  } from '../src/components/booking/availability-date-selection';
25
25
  import {
26
26
  buildChangeBookingServerPreview,
27
+ labelAdminAmendmentPriceSummaryLines,
27
28
  mapQuoteLineItemsToPriceSummaryLines,
28
29
  } from '../src/lib/booking/change-booking-server-preview';
29
30
  import {
30
31
  buildAdminChangeQuoteRequestKey,
32
+ canRequestAdminChangeQuote,
31
33
  canReuseCompletedAdminChangeQuote,
32
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';
33
45
  import {
34
46
  buildAdminChangeV2QuoteRequest,
35
47
  quoteAdminChangeV2ForUi,
@@ -37,16 +49,21 @@ import {
37
49
  import {
38
50
  noRefundRemovedValue,
39
51
  normalizeReturnPriceTreatment,
52
+ adminRemovalOperationLabel,
53
+ refundTreatmentDisplayValue,
40
54
  refundDecisionsComplete,
41
55
  releaseRefundDispositionOptions,
42
56
  returnPriceTreatmentOptions,
43
57
  } from '../src/components/booking/admin-refund-disposition';
44
58
  import {
59
+ defaultKeepOriginalReturnPriceDispositions,
60
+ defaultPendingRefundDispositions,
45
61
  mergeAdminRefundDecisionContext,
46
62
  selectRefundDispositionsForOperations,
47
63
  } from '../src/components/booking/admin-refund-decision-context';
48
64
  import {
49
65
  applyResourceAdjustments,
66
+ privateShuttleInitialDateKey,
50
67
  privateShuttlePriceChanged,
51
68
  resolveHydratedPrivateShuttleAvailability,
52
69
  } from '../src/components/booking/private-shuttle-availability';
@@ -57,13 +74,36 @@ import {
57
74
  reservationHoldSecondsRemaining,
58
75
  } from '../src/components/booking/reservation-hold';
59
76
  import { buildAdminChangeProviderPayload } from '../src/components/booking/admin-change-provider-payload';
60
- 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';
61
84
  import { collapseAdminPromoLines } from '../src/components/booking/admin-change-receipt-lines';
62
85
  import {
63
86
  buildAdminChangePaymentChoiceData,
64
87
  buildAdminChangePayNowCheckoutModalData,
65
88
  shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose,
66
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';
67
107
 
68
108
  function quote(overrides: Partial<ChangeBookingQuoteResponse>): ChangeBookingQuoteResponse {
69
109
  return {
@@ -106,6 +146,195 @@ function calendarAvailability(dateTime: string, vacancies: number): Availability
106
146
  return { dateTime, vacancies, currency: 'CAD' };
107
147
  }
108
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
+
109
338
  test('admin amendment display combines one promo allocation across component rows', () => {
110
339
  const lines = collapseAdminPromoLines([
111
340
  { kind: 'ticket', category: 'ADULT', qty: 1, itemTotal: 170.49 },
@@ -179,6 +408,48 @@ test('calendar auto-selection advances when the selected day has no visible star
179
408
  );
180
409
  });
181
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
+
182
453
  test('admin payment choice countdown expires exactly at the reservation deadline', () => {
183
454
  const expiration = '2026-07-16T15:46:44+00:00';
184
455
  assert.equal(
@@ -325,6 +596,26 @@ test('admin quote dedupe refetches unchanged inputs after the published quote is
325
596
  assert.equal(canReuseCompletedAdminChangeQuote(inputKey, 'different-inputs', true), false);
326
597
  });
327
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
+
328
619
  test('admin preview and checkout share the Pricing V2 request shape', () => {
329
620
  const request = buildAdminChangeV2QuoteRequest({
330
621
  bookingReference: ' VFSCAEKQ ',
@@ -345,6 +636,7 @@ test('admin preview and checkout share the Pricing V2 request shape', () => {
345
636
  promoCode: ' banffblog15 ',
346
637
  promoApplicationScope: 'ENTIRE_BOOKING',
347
638
  structuredAdjustments: [{ type: 'CUSTOM', amount: 5 }],
639
+ includeSelectableAdjustmentComponents: true,
348
640
  refundDispositionsByOperationId: { remove_1: 'PENDING_REFUND' },
349
641
  previousPassengerCount: 2,
350
642
  previousAvailabilityId: 'availability_old',
@@ -358,6 +650,7 @@ test('admin preview and checkout share the Pricing V2 request shape', () => {
358
650
  assert.equal(request.promoCode, 'banffblog15');
359
651
  assert.equal(request.promoApplicationScope, 'ENTIRE_BOOKING');
360
652
  assert.deepEqual(request.refundDispositionsByOperationId, { remove_1: 'PENDING_REFUND' });
653
+ assert.equal(request.includeSelectableAdjustmentComponents, true);
361
654
  assert.deepEqual(request.capacitySeatCredit, {
362
655
  enabled: true,
363
656
  previousPassengerCount: 2,
@@ -517,8 +810,22 @@ test('admin removal quote preserves explicit release refund decisions for the pr
517
810
  quantityDelta: -1,
518
811
  };
519
812
  const mapped = mapAdminChangeBookingQuoteV2Data({
520
- oldReceipt: { currency: 'CAD', grossSubtotal: 357.46, taxAmount: 30.38, payableTotal: 387.84 },
521
- 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
+ },
522
829
  canApply: false,
523
830
  reasonIfBlocked: 'admin_refund_disposition_required',
524
831
  refundDecisionOperations: [operation],
@@ -538,6 +845,10 @@ test('admin removal quote preserves explicit release refund decisions for the pr
538
845
  );
539
846
 
540
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);
541
852
  assert.equal(slice.removedValueByOperationId?.[operation.operationId], 193.92);
542
853
  assert.deepEqual(
543
854
  releaseRefundDispositionOptions(
@@ -643,11 +954,62 @@ test('admin refund decisions remain stable when successive quotes return only un
643
954
  );
644
955
  });
645
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
+
646
1007
  test('admin move-service quote labels only its ticket price difference', () => {
647
1008
  const moveOperation = {
648
1009
  operationId: 'op_move_service',
649
1010
  type: 'MOVE_SERVICE',
650
1011
  componentKey: 'service',
1012
+ changedFields: ['availabilityId', 'dateTime'],
651
1013
  };
652
1014
  const addOperation = {
653
1015
  operationId: 'op_add_adult',
@@ -830,6 +1192,46 @@ test('admin return price treatment defaults to keep floor and maps optional repr
830
1192
  );
831
1193
  });
832
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
+
833
1235
  test('unsupported immediate refund cannot become a return price treatment', () => {
834
1236
  assert.equal(normalizeReturnPriceTreatment(undefined), 'KEEP_FLOOR');
835
1237
  assert.equal(normalizeReturnPriceTreatment('REFUND_TO_ORIGINAL_PAYMENT'), 'KEEP_FLOOR');
@@ -951,6 +1353,142 @@ test('private shuttle pricing replaces the selected calendar summary with hydrat
951
1353
  assert.equal(applyResourceAdjustments(1699.99, resolved, 'CAD'), 2099.99);
952
1354
  });
953
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
+
954
1492
  test('private shuttle checkout detects a quote total that differs from the displayed total', () => {
955
1493
  assert.equal(privateShuttlePriceChanged(2247, 2681), true);
956
1494
  assert.equal(privateShuttlePriceChanged(2681, 2681.0), false);
@@ -1260,3 +1798,315 @@ test('builds paid checkout modal data with trimmed customer and change totals',
1260
1798
  differenceTotal: 12,
1261
1799
  });
1262
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
+ });