@ticketboothapp/booking 1.2.166 → 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 (29) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +2 -0
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +95 -30
  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-provider-payload.ts +9 -1
  12. package/src/components/booking/admin-change-quote-request-key.ts +14 -1
  13. package/src/components/booking/admin-change-receipt-lines.ts +49 -0
  14. package/src/components/booking/admin-change-v2-quote-request.ts +101 -0
  15. package/src/components/booking/admin-refund-decision-context.ts +85 -0
  16. package/src/components/booking/availability-date-selection.ts +16 -0
  17. package/src/components/booking/provider-dashboard-change-booking.ts +2 -1
  18. package/src/components/booking/use-standard-booking-availability.ts +5 -2
  19. package/src/components/booking/useAdminChangeCheckoutController.ts +40 -1
  20. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +2 -2
  21. package/src/components/booking/useAdminChangeQuotePreview.ts +72 -43
  22. package/src/components/booking/useBookingAvailabilityAddOns.ts +7 -2
  23. package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
  24. package/src/components/booking/useChangeBookingAutoSelections.ts +60 -17
  25. package/src/components/booking/useStandardBookingAutoSelections.ts +55 -7
  26. package/src/lib/booking/booking-cutoffs.ts +22 -0
  27. package/src/lib/booking/change-booking-server-preview.ts +75 -5
  28. package/src/lib/booking-api.ts +12 -1
  29. package/test/change-booking-helpers.test.ts +466 -4
@@ -11,9 +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';
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
+ mapQuoteLineItemsToPriceSummaryLines,
28
+ } from '../src/lib/booking/change-booking-server-preview';
29
+ import {
30
+ buildAdminChangeQuoteRequestKey,
31
+ canReuseCompletedAdminChangeQuote,
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';
17
37
  import {
18
38
  noRefundRemovedValue,
19
39
  normalizeReturnPriceTreatment,
@@ -21,6 +41,10 @@ import {
21
41
  releaseRefundDispositionOptions,
22
42
  returnPriceTreatmentOptions,
23
43
  } from '../src/components/booking/admin-refund-disposition';
44
+ import {
45
+ mergeAdminRefundDecisionContext,
46
+ selectRefundDispositionsForOperations,
47
+ } from '../src/components/booking/admin-refund-decision-context';
24
48
  import {
25
49
  applyResourceAdjustments,
26
50
  privateShuttlePriceChanged,
@@ -34,6 +58,7 @@ import {
34
58
  } from '../src/components/booking/reservation-hold';
35
59
  import { buildAdminChangeProviderPayload } from '../src/components/booking/admin-change-provider-payload';
36
60
  import { buildPublicChangeAmountDueSummary } from '../src/components/booking/change-booking-flow-helpers';
61
+ import { collapseAdminPromoLines } from '../src/components/booking/admin-change-receipt-lines';
37
62
  import {
38
63
  buildAdminChangePaymentChoiceData,
39
64
  buildAdminChangePayNowCheckoutModalData,
@@ -57,16 +82,103 @@ function quoteSlice(overrides: Partial<ChangeQuoteUiSlice>): ChangeQuoteUiSlice
57
82
  };
58
83
  }
59
84
 
60
- function test(name: string, fn: () => void): void {
85
+ function test(name: string, fn: () => void | Promise<void>): void {
61
86
  try {
62
- fn();
63
- 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
+ }
64
99
  } catch (error) {
65
100
  console.error(`not ok - ${name}`);
66
101
  throw error;
67
102
  }
68
103
  }
69
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
+
70
182
  test('admin payment choice countdown expires exactly at the reservation deadline', () => {
71
183
  const expiration = '2026-07-16T15:46:44+00:00';
72
184
  assert.equal(
@@ -206,6 +318,100 @@ test('admin quote response display state cannot invalidate its request identity'
206
318
  assert.equal(keyAfterResponse, keyBeforeResponse);
207
319
  });
208
320
 
321
+ test('admin quote dedupe refetches unchanged inputs after the published quote is cleared', () => {
322
+ const inputKey = 'same-admin-change-inputs';
323
+ assert.equal(canReuseCompletedAdminChangeQuote(inputKey, inputKey, true), true);
324
+ assert.equal(canReuseCompletedAdminChangeQuote(inputKey, inputKey, false), false);
325
+ assert.equal(canReuseCompletedAdminChangeQuote(inputKey, 'different-inputs', true), false);
326
+ });
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
+
209
415
  test('admin no-charge provider payload carries the selected pickup itinerary', () => {
210
416
  const itineraryDisplay: NonNullable<
211
417
  Parameters<typeof buildAdminChangeProviderPayload>[0]['itineraryDisplay']
@@ -235,6 +441,7 @@ test('admin no-charge provider payload carries the selected pickup itinerary', (
235
441
  cancellationPolicyId: 'standard',
236
442
  initialCancellationPolicyId: 'standard',
237
443
  appliedPromoCode: null,
444
+ promoApplicationScope: 'CHANGE_ONLY',
238
445
  newTotalAmount: 202.87,
239
446
  providerPricingOverrides: [],
240
447
  mergedProviderAdditionalAdjustments: [],
@@ -255,6 +462,7 @@ test('admin no-charge provider payload carries the selected pickup itinerary', (
255
462
  assert.deepEqual(payload?.addOnSelections, []);
256
463
  assert.equal(payload?.pricingV2QuoteId, 'quote_reviewed');
257
464
  assert.equal(payload?.pricingV2QuotedTotal, 202.87);
465
+ assert.equal(payload?.promoApplicationScope, null);
258
466
  });
259
467
 
260
468
  test('public paid change preview shows amendment-only charges before combined tax', () => {
@@ -349,6 +557,249 @@ test('admin removal quote preserves explicit release refund decisions for the pr
349
557
  );
350
558
  });
351
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
+
352
803
  test('admin return price treatment defaults to keep floor and maps optional reprice choices', () => {
353
804
  const operation = {
354
805
  operationId: 'op_change_return',
@@ -443,9 +894,20 @@ test('admin cancellation policy and structured adjustments are authoritative quo
443
894
  ...base,
444
895
  structuredAdjustments: [{ adjustmentId: 'adj_1', mode: 'FIXED_AMOUNT', fixedAmount: 10 }],
445
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
+ });
446
907
 
447
908
  assert.notEqual(original, policyChanged);
448
909
  assert.notEqual(original, adjustmentChanged);
910
+ assert.notEqual(changeOnlyPromo, wholeBookingPromo);
449
911
  });
450
912
 
451
913
  test('private shuttle pricing replaces the selected calendar summary with hydrated rate details', () => {