@ticketboothapp/booking 1.2.165 → 1.2.167

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 (30) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +2 -0
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +97 -32
  4. package/src/components/booking/AdminChangeCheckoutPanel.tsx +6 -0
  5. package/src/components/booking/AdminChangePromoEditor.tsx +97 -0
  6. package/src/components/booking/AdminChangeReceiptComparison.tsx +9 -2
  7. package/src/components/booking/NewBookingFlow.tsx +14 -2
  8. package/src/components/booking/PriceBreakdown.tsx +66 -15
  9. package/src/components/booking/PriceSummary.tsx +12 -0
  10. package/src/components/booking/PrivateShuttleBookingFlow.tsx +25 -1
  11. package/src/components/booking/admin-change-payment-choice-runner.ts +12 -0
  12. package/src/components/booking/admin-change-provider-payload.ts +9 -1
  13. package/src/components/booking/admin-change-quote-request-key.ts +6 -6
  14. package/src/components/booking/admin-change-receipt-lines.ts +49 -0
  15. package/src/components/booking/admin-change-v2-quote-request.ts +101 -0
  16. package/src/components/booking/admin-refund-decision-context.ts +85 -0
  17. package/src/components/booking/availability-date-selection.ts +16 -0
  18. package/src/components/booking/provider-dashboard-change-booking.ts +2 -1
  19. package/src/components/booking/use-standard-booking-availability.ts +5 -2
  20. package/src/components/booking/useAdminChangeCheckoutController.ts +90 -11
  21. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +2 -2
  22. package/src/components/booking/useAdminChangeQuotePreview.ts +47 -37
  23. package/src/components/booking/useBookingAvailabilityAddOns.ts +7 -2
  24. package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
  25. package/src/components/booking/useChangeBookingAutoSelections.ts +60 -17
  26. package/src/components/booking/useStandardBookingAutoSelections.ts +55 -7
  27. package/src/lib/booking/booking-cutoffs.ts +22 -0
  28. package/src/lib/booking/change-booking-server-preview.ts +75 -5
  29. package/src/lib/booking-api.ts +12 -1
  30. package/test/change-booking-helpers.test.ts +466 -3
@@ -11,12 +11,29 @@ 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';
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
+ mapQuoteLineItemsToPriceSummaryLines,
28
+ } from '../src/lib/booking/change-booking-server-preview';
16
29
  import {
17
30
  buildAdminChangeQuoteRequestKey,
18
31
  canReuseCompletedAdminChangeQuote,
19
32
  } from '../src/components/booking/admin-change-quote-request-key';
33
+ import {
34
+ buildAdminChangeV2QuoteRequest,
35
+ quoteAdminChangeV2ForUi,
36
+ } from '../src/components/booking/admin-change-v2-quote-request';
20
37
  import {
21
38
  noRefundRemovedValue,
22
39
  normalizeReturnPriceTreatment,
@@ -24,6 +41,10 @@ import {
24
41
  releaseRefundDispositionOptions,
25
42
  returnPriceTreatmentOptions,
26
43
  } from '../src/components/booking/admin-refund-disposition';
44
+ import {
45
+ mergeAdminRefundDecisionContext,
46
+ selectRefundDispositionsForOperations,
47
+ } from '../src/components/booking/admin-refund-decision-context';
27
48
  import {
28
49
  applyResourceAdjustments,
29
50
  privateShuttlePriceChanged,
@@ -37,9 +58,11 @@ import {
37
58
  } from '../src/components/booking/reservation-hold';
38
59
  import { buildAdminChangeProviderPayload } from '../src/components/booking/admin-change-provider-payload';
39
60
  import { buildPublicChangeAmountDueSummary } from '../src/components/booking/change-booking-flow-helpers';
61
+ import { collapseAdminPromoLines } from '../src/components/booking/admin-change-receipt-lines';
40
62
  import {
41
63
  buildAdminChangePaymentChoiceData,
42
64
  buildAdminChangePayNowCheckoutModalData,
65
+ shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose,
43
66
  } from '../src/components/booking/admin-change-payment-choice-runner';
44
67
 
45
68
  function quote(overrides: Partial<ChangeBookingQuoteResponse>): ChangeBookingQuoteResponse {
@@ -59,16 +82,103 @@ function quoteSlice(overrides: Partial<ChangeQuoteUiSlice>): ChangeQuoteUiSlice
59
82
  };
60
83
  }
61
84
 
62
- function test(name: string, fn: () => void): void {
85
+ function test(name: string, fn: () => void | Promise<void>): void {
63
86
  try {
64
- fn();
65
- console.log(`ok - ${name}`);
87
+ const result = fn();
88
+ if (result instanceof Promise) {
89
+ void result.then(
90
+ () => console.log(`ok - ${name}`),
91
+ (error) => {
92
+ console.error(`not ok - ${name}`);
93
+ throw error;
94
+ },
95
+ );
96
+ } else {
97
+ console.log(`ok - ${name}`);
98
+ }
66
99
  } catch (error) {
67
100
  console.error(`not ok - ${name}`);
68
101
  throw error;
69
102
  }
70
103
  }
71
104
 
105
+ function calendarAvailability(dateTime: string, vacancies: number): Availability {
106
+ return { dateTime, vacancies, currency: 'CAD' };
107
+ }
108
+
109
+ test('admin amendment display combines one promo allocation across component rows', () => {
110
+ const lines = collapseAdminPromoLines([
111
+ { kind: 'ticket', category: 'ADULT', qty: 1, itemTotal: 170.49 },
112
+ { kind: 'line', label: 'Moraine Lake Road Access Fee', amount: 15.99, type: 'FEE' },
113
+ { kind: 'line', label: 'Discount · Moraine Lake Road Access Fee', amount: -2.4, type: 'DISCOUNT' },
114
+ { kind: 'line', label: 'Discount · ADULT', amount: -25.57, type: 'DISCOUNT' },
115
+ ], 'banffblog15');
116
+
117
+ assert.deepEqual(lines, [
118
+ { kind: 'ticket', category: 'ADULT', qty: 1, itemTotal: 170.49 },
119
+ { kind: 'line', label: 'Moraine Lake Road Access Fee', amount: 15.99, type: 'FEE' },
120
+ { kind: 'line', label: 'Promo: BANFFBLOG15', amount: -27.97, type: 'PROMO_CODE' },
121
+ ]);
122
+ });
123
+
124
+ test('admin amendment display leaves unrelated manual discounts separate', () => {
125
+ const lines = collapseAdminPromoLines([
126
+ { kind: 'line', label: 'Promo retained booking value', amount: -10, type: 'DISCOUNT' },
127
+ { kind: 'line', label: 'Manager courtesy', amount: -5, type: 'DISCOUNT' },
128
+ { kind: 'line', label: 'Discount · ADULT', amount: -20, type: 'PROMO_CODE' },
129
+ ], 'SAVE20');
130
+
131
+ assert.deepEqual(lines, [
132
+ { kind: 'line', label: 'Promo: SAVE20', amount: -30, type: 'PROMO_CODE' },
133
+ { kind: 'line', label: 'Manager courtesy', amount: -5, type: 'DISCOUNT' },
134
+ ]);
135
+ });
136
+
137
+ test('admin availability filtering removes only starts that have passed', () => {
138
+ const now = new Date('2026-07-21T17:00:00-06:00');
139
+ const rows = [
140
+ calendarAvailability('2026-07-21T16:59:00-06:00', 4),
141
+ calendarAvailability('2026-07-21T17:01:00-06:00', 4),
142
+ calendarAvailability('2026-07-22T08:00:00-06:00', 4),
143
+ ];
144
+
145
+ assert.deepEqual(
146
+ filterAvailabilitiesAfterCurrentTime(rows, now).map((row) => row.dateTime),
147
+ ['2026-07-21T17:01:00-06:00', '2026-07-22T08:00:00-06:00'],
148
+ );
149
+ });
150
+
151
+ test('public availability filtering keeps its stricter advance cutoff', () => {
152
+ const now = new Date('2026-07-21T17:00:00-06:00');
153
+ const rows = [
154
+ calendarAvailability('2026-07-21T17:30:00-06:00', 4),
155
+ calendarAvailability('2026-07-21T18:30:00-06:00', 4),
156
+ ];
157
+
158
+ assert.deepEqual(
159
+ filterAvailabilitiesAfterPublicBookingCutoff(rows, now, 60).map((row) => row.dateTime),
160
+ ['2026-07-21T18:30:00-06:00'],
161
+ );
162
+ });
163
+
164
+ test('calendar auto-selection advances when the selected day has no visible starts', () => {
165
+ const rowsByDate: Record<string, Availability[]> = {
166
+ '2026-07-22': [calendarAvailability('2026-07-22T08:00:00-06:00', 0)],
167
+ '2026-07-23': [calendarAvailability('2026-07-23T08:00:00-06:00', 3)],
168
+ };
169
+ const getRowsForDate = (date: string) => rowsByDate[date] ?? [];
170
+
171
+ assert.equal(selectedDateHasVisibleAvailability('2026-07-21', getRowsForDate), false);
172
+ assert.equal(
173
+ findFirstDateWithBookableAvailability(
174
+ ['2026-07-22', '2026-07-23'],
175
+ getRowsForDate,
176
+ (availability) => availability.vacancies > 0,
177
+ ),
178
+ '2026-07-23',
179
+ );
180
+ });
181
+
72
182
  test('admin payment choice countdown expires exactly at the reservation deadline', () => {
73
183
  const expiration = '2026-07-16T15:46:44+00:00';
74
184
  assert.equal(
@@ -120,6 +230,16 @@ test('admin existing-booking pay-now review keeps previous, updated, and exact d
120
230
  assert.equal(checkout.finalizeExistingChangeInline, true);
121
231
  assert.equal(choice.confirmWithoutPaymentLabel, 'Pay later');
122
232
  assert.equal(choice.preferConfirmWithoutPayment, true);
233
+ assert.equal(
234
+ shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose(true, true, choice),
235
+ true,
236
+ );
237
+ assert.equal(
238
+ shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose(true, true, {
239
+ pricingQuoteId: ' ',
240
+ }),
241
+ false,
242
+ );
123
243
  assert.equal(
124
244
  buildAdminChangePayNowCheckoutModalData(
125
245
  { ...choice, finalizeExistingChangeInline: undefined },
@@ -205,6 +325,93 @@ test('admin quote dedupe refetches unchanged inputs after the published quote is
205
325
  assert.equal(canReuseCompletedAdminChangeQuote(inputKey, 'different-inputs', true), false);
206
326
  });
207
327
 
328
+ test('admin preview and checkout share the Pricing V2 request shape', () => {
329
+ const request = buildAdminChangeV2QuoteRequest({
330
+ bookingReference: ' VFSCAEKQ ',
331
+ lastName: ' Tauro ',
332
+ parentProductId: 'product_1',
333
+ optionId: 'option_1',
334
+ selectedAvailability: {
335
+ availabilityId: 'availability_1',
336
+ productId: 'option_1',
337
+ dateTime: '2026-07-28T08:35:00-06:00',
338
+ vacancies: 10,
339
+ } as Parameters<typeof buildAdminChangeV2QuoteRequest>[0]['selectedAvailability'],
340
+ pickupLocationId: 'pickup_1',
341
+ returnAvailabilityId: null,
342
+ bookingItems: [{ category: 'ADULT', count: 3 }],
343
+ addOnSelections: [],
344
+ cancellationPolicyId: 'policy_1',
345
+ promoCode: ' banffblog15 ',
346
+ promoApplicationScope: 'ENTIRE_BOOKING',
347
+ structuredAdjustments: [{ type: 'CUSTOM', amount: 5 }],
348
+ refundDispositionsByOperationId: { remove_1: 'PENDING_REFUND' },
349
+ previousPassengerCount: 2,
350
+ previousAvailabilityId: 'availability_old',
351
+ previousReturnAvailabilityId: null,
352
+ });
353
+
354
+ assert.equal(request.bookingReference, 'VFSCAEKQ');
355
+ assert.equal(request.lastName, 'Tauro');
356
+ assert.equal(request.newProductId, 'option_1');
357
+ assert.deepEqual(request.newPassengerCounts, [{ category: 'ADULT', count: 3 }]);
358
+ assert.equal(request.promoCode, 'banffblog15');
359
+ assert.equal(request.promoApplicationScope, 'ENTIRE_BOOKING');
360
+ assert.deepEqual(request.refundDispositionsByOperationId, { remove_1: 'PENDING_REFUND' });
361
+ assert.deepEqual(request.capacitySeatCredit, {
362
+ enabled: true,
363
+ previousPassengerCount: 2,
364
+ previousAvailabilityId: 'availability_old',
365
+ previousReturnAvailabilityId: null,
366
+ });
367
+ assert.equal('clientProposedTotal' in request, false);
368
+ });
369
+
370
+ test('admin checkout maps the fresh Pricing V2 quote id instead of a legacy response', async () => {
371
+ const request = buildAdminChangeV2QuoteRequest({
372
+ bookingReference: 'VFSCAEKQ',
373
+ lastName: 'Tauro',
374
+ parentProductId: 'product_1',
375
+ optionId: 'option_1',
376
+ selectedAvailability: {
377
+ availabilityId: 'availability_1',
378
+ productId: 'option_1',
379
+ dateTime: '2026-07-28T08:35:00-06:00',
380
+ vacancies: 10,
381
+ } as Parameters<typeof buildAdminChangeV2QuoteRequest>[0]['selectedAvailability'],
382
+ pickupLocationId: 'pickup_1',
383
+ returnAvailabilityId: null,
384
+ bookingItems: [{ category: 'ADULT', count: 3 }],
385
+ addOnSelections: [],
386
+ cancellationPolicyId: 'policy_1',
387
+ structuredAdjustments: [],
388
+ refundDispositionsByOperationId: {},
389
+ previousPassengerCount: 2,
390
+ previousAvailabilityId: 'availability_old',
391
+ previousReturnAvailabilityId: null,
392
+ });
393
+ const mergedQuote = await quoteAdminChangeV2ForUi(
394
+ request,
395
+ { total: 768.11, subtotal: 707.94, tax: 60.17 },
396
+ 'CAD',
397
+ async () => quote({
398
+ pricingQuote: {
399
+ quoteId: 'quote_v2_checkout',
400
+ currency: 'CAD',
401
+ payableTotal: 768.11,
402
+ amountToCharge: 256.03,
403
+ balanceDelta: 256.03,
404
+ lines: [],
405
+ },
406
+ balanceDeltaMajorUnits: 256.03,
407
+ }),
408
+ );
409
+
410
+ assert.equal(mergedQuote.quoteId, 'quote_v2_checkout');
411
+ assert.equal(mergedQuote.priceDiff, 256.03);
412
+ assert.equal(mergedQuote.quotedTotal, 768.11);
413
+ });
414
+
208
415
  test('admin no-charge provider payload carries the selected pickup itinerary', () => {
209
416
  const itineraryDisplay: NonNullable<
210
417
  Parameters<typeof buildAdminChangeProviderPayload>[0]['itineraryDisplay']
@@ -234,6 +441,7 @@ test('admin no-charge provider payload carries the selected pickup itinerary', (
234
441
  cancellationPolicyId: 'standard',
235
442
  initialCancellationPolicyId: 'standard',
236
443
  appliedPromoCode: null,
444
+ promoApplicationScope: 'CHANGE_ONLY',
237
445
  newTotalAmount: 202.87,
238
446
  providerPricingOverrides: [],
239
447
  mergedProviderAdditionalAdjustments: [],
@@ -254,6 +462,7 @@ test('admin no-charge provider payload carries the selected pickup itinerary', (
254
462
  assert.deepEqual(payload?.addOnSelections, []);
255
463
  assert.equal(payload?.pricingV2QuoteId, 'quote_reviewed');
256
464
  assert.equal(payload?.pricingV2QuotedTotal, 202.87);
465
+ assert.equal(payload?.promoApplicationScope, null);
257
466
  });
258
467
 
259
468
  test('public paid change preview shows amendment-only charges before combined tax', () => {
@@ -348,6 +557,249 @@ test('admin removal quote preserves explicit release refund decisions for the pr
348
557
  );
349
558
  });
350
559
 
560
+ test('admin refund decisions remain stable when successive quotes return only unresolved operations', () => {
561
+ const removeReturn = {
562
+ operationId: 'op_remove_return',
563
+ type: 'REMOVE_RETURN',
564
+ componentKey: 'return:rt_morning',
565
+ quantityDelta: -1,
566
+ };
567
+ const removeAdult = {
568
+ operationId: 'op_remove_adult',
569
+ type: 'REMOVE_QUANTITY',
570
+ componentKey: 'ticket:ADULT',
571
+ quantityDelta: -1,
572
+ };
573
+ const allowed = ['PENDING_REFUND', 'NO_REFUND'] as const;
574
+
575
+ const initial = mergeAdminRefundDecisionContext({
576
+ current: null,
577
+ selectionKey: 'selection-a',
578
+ operations: [removeReturn, removeAdult],
579
+ allowedByOperationId: {
580
+ [removeReturn.operationId]: [...allowed],
581
+ [removeAdult.operationId]: [...allowed],
582
+ },
583
+ removedValueByOperationId: {
584
+ [removeReturn.operationId]: 42.32,
585
+ [removeAdult.operationId]: 220.83,
586
+ },
587
+ });
588
+ const afterReturnDecision = mergeAdminRefundDecisionContext({
589
+ current: initial,
590
+ selectionKey: 'selection-a',
591
+ // The server now returns only the still-unanswered quantity removal.
592
+ operations: [removeAdult],
593
+ allowedByOperationId: { [removeAdult.operationId]: [...allowed] },
594
+ removedValueByOperationId: { [removeAdult.operationId]: 220.83 },
595
+ });
596
+
597
+ assert.deepEqual(
598
+ afterReturnDecision?.operations.map((operation) => operation.operationId),
599
+ [removeReturn.operationId, removeAdult.operationId],
600
+ );
601
+ assert.deepEqual(
602
+ selectRefundDispositionsForOperations(afterReturnDecision, [], {
603
+ [removeReturn.operationId]: 'PENDING_REFUND',
604
+ }),
605
+ { [removeReturn.operationId]: 'PENDING_REFUND' },
606
+ );
607
+
608
+ const complete = mergeAdminRefundDecisionContext({
609
+ current: afterReturnDecision,
610
+ selectionKey: 'selection-a',
611
+ operations: [],
612
+ allowedByOperationId: {},
613
+ removedValueByOperationId: {},
614
+ });
615
+ assert.deepEqual(
616
+ selectRefundDispositionsForOperations(complete, [], {
617
+ [removeReturn.operationId]: 'PENDING_REFUND',
618
+ [removeAdult.operationId]: 'NO_REFUND',
619
+ }),
620
+ {
621
+ [removeReturn.operationId]: 'PENDING_REFUND',
622
+ [removeAdult.operationId]: 'NO_REFUND',
623
+ },
624
+ );
625
+
626
+ const nextSelection = mergeAdminRefundDecisionContext({
627
+ current: complete,
628
+ selectionKey: 'selection-b',
629
+ operations: [removeAdult],
630
+ allowedByOperationId: { [removeAdult.operationId]: [...allowed] },
631
+ removedValueByOperationId: { [removeAdult.operationId]: 220.83 },
632
+ });
633
+ assert.deepEqual(
634
+ nextSelection?.operations.map((operation) => operation.operationId),
635
+ [removeAdult.operationId],
636
+ );
637
+ assert.deepEqual(
638
+ selectRefundDispositionsForOperations(nextSelection, [], {
639
+ [removeReturn.operationId]: 'PENDING_REFUND',
640
+ [removeAdult.operationId]: 'NO_REFUND',
641
+ }),
642
+ { [removeAdult.operationId]: 'NO_REFUND' },
643
+ );
644
+ });
645
+
646
+ test('admin move-service quote labels only its ticket price difference', () => {
647
+ const moveOperation = {
648
+ operationId: 'op_move_service',
649
+ type: 'MOVE_SERVICE',
650
+ componentKey: 'service',
651
+ };
652
+ const addOperation = {
653
+ operationId: 'op_add_adult',
654
+ type: 'ADD_QUANTITY',
655
+ componentKey: 'ticket:ADULT',
656
+ quantityDelta: 1,
657
+ };
658
+ const mapped = mapAdminChangeBookingQuoteV2Data({
659
+ oldReceipt: {
660
+ currency: 'CAD',
661
+ grossSubtotal: 247.96,
662
+ taxAmount: 21.08,
663
+ payableTotal: 269.04,
664
+ lines: [
665
+ {
666
+ type: 'TICKET',
667
+ label: 'ADULT',
668
+ quantity: 2,
669
+ amount: 215.98,
670
+ billable: true,
671
+ priceBasis: {
672
+ baseUnitAmount: 139.99,
673
+ finalUnitAmount: 107.99,
674
+ currency: 'CAD',
675
+ adjustments: [
676
+ {
677
+ type: 'DYNAMIC_PRICING',
678
+ id: 'rule_early_booking',
679
+ name: 'Early booking discount',
680
+ amount: -32,
681
+ adjustmentType: 'FIXED',
682
+ adjustmentValue: 32,
683
+ },
684
+ ],
685
+ },
686
+ },
687
+ ],
688
+ },
689
+ newQuote: {
690
+ currency: 'CAD',
691
+ payableTotal: 455.64,
692
+ balanceDelta: 186.60,
693
+ amendment: { operations: [moveOperation, addOperation] },
694
+ lines: [
695
+ {
696
+ type: 'TICKET',
697
+ label: 'ADULT',
698
+ quantity: 2,
699
+ amount: 48,
700
+ billable: true,
701
+ metadata: { category: 'ADULT', operationId: moveOperation.operationId },
702
+ priceBasis: {
703
+ baseUnitAmount: 149.99,
704
+ finalUnitAmount: 131.99,
705
+ currency: 'CAD',
706
+ adjustments: [
707
+ {
708
+ type: 'dynamic',
709
+ id: 'rule_new_date',
710
+ name: 'New-date adjustment',
711
+ amount: -18,
712
+ adjustmentType: 'fixed',
713
+ adjustmentValue: -18,
714
+ },
715
+ ],
716
+ },
717
+ },
718
+ {
719
+ type: 'TICKET',
720
+ label: 'ADULT',
721
+ quantity: 1,
722
+ amount: 124,
723
+ billable: true,
724
+ metadata: { category: 'ADULT', operationId: addOperation.operationId },
725
+ },
726
+ ],
727
+ },
728
+ canApply: true,
729
+ });
730
+ assert.deepEqual(mapped.originalReceipt?.lineItems?.[0]?.priceBasis, {
731
+ baseUnitAmount: 139.99,
732
+ finalUnitAmount: 107.99,
733
+ currency: 'CAD',
734
+ adjustments: [
735
+ {
736
+ type: 'DYNAMIC_PRICING',
737
+ id: 'rule_early_booking',
738
+ name: 'Early booking discount',
739
+ amount: -32,
740
+ adjustmentType: 'FIXED',
741
+ adjustmentValue: 32,
742
+ },
743
+ ],
744
+ });
745
+ const existingPurchaseLines = mapQuoteLineItemsToPriceSummaryLines(
746
+ mapped.originalReceipt?.lineItems,
747
+ );
748
+ assert.deepEqual(
749
+ existingPurchaseLines[0]?.kind === 'ticket'
750
+ ? existingPurchaseLines[0].breakdown?.lineItems
751
+ : null,
752
+ [
753
+ { type: 'base', name: 'Base price', amountInDisplayCurrency: 139.99 },
754
+ {
755
+ type: 'adjustment',
756
+ id: 'rule_early_booking',
757
+ name: 'Early booking discount',
758
+ amountInDisplayCurrency: -32,
759
+ adjustmentType: 'FIXED',
760
+ adjustmentValue: 32,
761
+ sourceType: 'dynamic',
762
+ },
763
+ ],
764
+ );
765
+ const preview = buildChangeBookingServerPreview(
766
+ mapped,
767
+ { total: 455.64, subtotal: 419.94, tax: 35.70 },
768
+ 'CAD',
769
+ );
770
+ const lines = preview?.priceSummaryLines ?? [];
771
+
772
+ assert.equal(lines[0]?.kind, 'ticket');
773
+ assert.equal(lines[0]?.kind === 'ticket' ? lines[0].category : null, 'ADULT — new date price difference');
774
+ assert.deepEqual(
775
+ lines[0]?.kind === 'ticket' ? lines[0].unitPriceComparison : null,
776
+ {
777
+ previousLabel: 'Existing adult price',
778
+ updatedLabel: 'New-date adult price',
779
+ differenceLabel: 'Difference per adult',
780
+ previousUnitAmount: 107.99,
781
+ updatedUnitAmount: 131.99,
782
+ differenceUnitAmount: 24,
783
+ },
784
+ );
785
+ assert.deepEqual(
786
+ lines[0]?.kind === 'ticket' ? lines[0].breakdown?.lineItems : null,
787
+ [
788
+ { type: 'base', name: 'Base price', amountInDisplayCurrency: 149.99 },
789
+ {
790
+ type: 'adjustment',
791
+ id: 'rule_new_date',
792
+ name: 'New-date adjustment',
793
+ amountInDisplayCurrency: -18,
794
+ adjustmentType: 'fixed',
795
+ adjustmentValue: -18,
796
+ sourceType: 'dynamic',
797
+ },
798
+ ],
799
+ );
800
+ assert.equal(lines[1]?.kind === 'ticket' ? lines[1].category : null, 'ADULT');
801
+ });
802
+
351
803
  test('admin return price treatment defaults to keep floor and maps optional reprice choices', () => {
352
804
  const operation = {
353
805
  operationId: 'op_change_return',
@@ -442,9 +894,20 @@ test('admin cancellation policy and structured adjustments are authoritative quo
442
894
  ...base,
443
895
  structuredAdjustments: [{ adjustmentId: 'adj_1', mode: 'FIXED_AMOUNT', fixedAmount: 10 }],
444
896
  });
897
+ const wholeBookingPromo = buildAdminChangeQuoteRequestKey({
898
+ ...base,
899
+ promoCode: 'BANFFBLOG15',
900
+ promoApplicationScope: 'ENTIRE_BOOKING',
901
+ });
902
+ const changeOnlyPromo = buildAdminChangeQuoteRequestKey({
903
+ ...base,
904
+ promoCode: 'BANFFBLOG15',
905
+ promoApplicationScope: 'CHANGE_ONLY',
906
+ });
445
907
 
446
908
  assert.notEqual(original, policyChanged);
447
909
  assert.notEqual(original, adjustmentChanged);
910
+ assert.notEqual(changeOnlyPromo, wholeBookingPromo);
448
911
  });
449
912
 
450
913
  test('private shuttle pricing replaces the selected calendar summary with hydrated rate details', () => {