@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.
- package/package.json +1 -1
- package/src/components/booking/AdminChangeBookingContent.tsx +15 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +274 -94
- package/src/components/booking/AdminChangeCheckoutPanel.tsx +49 -13
- package/src/components/booking/AdminChangePricingPanel.tsx +400 -134
- package/src/components/booking/AdminChangePromoEditor.tsx +97 -0
- package/src/components/booking/AdminChangeReceiptComparison.tsx +172 -147
- package/src/components/booking/ChangeBookingFlow.tsx +6 -3
- package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +7 -2
- package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +6 -0
- package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +80 -0
- package/src/components/booking/NewBookingFlow.tsx +14 -2
- package/src/components/booking/PickupLocationSelector.module.css +22 -0
- package/src/components/booking/PickupLocationSelector.tsx +3 -3
- package/src/components/booking/PriceBreakdown.tsx +66 -15
- package/src/components/booking/PriceSummary.tsx +16 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +52 -14
- package/src/components/booking/PrivateShuttleCheckoutSection.tsx +95 -54
- package/src/components/booking/admin-adjustment-components.ts +44 -0
- package/src/components/booking/admin-adjustment-tax-behavior.ts +50 -0
- package/src/components/booking/admin-change-flow-state-helpers.ts +10 -0
- package/src/components/booking/admin-change-provider-payload.ts +12 -1
- package/src/components/booking/admin-change-quote-request-key.ts +41 -1
- package/src/components/booking/admin-change-receipt-lines.ts +49 -0
- package/src/components/booking/admin-change-v2-quote-request.ts +109 -0
- package/src/components/booking/admin-refund-decision-context.ts +122 -0
- package/src/components/booking/admin-refund-disposition.ts +71 -2
- package/src/components/booking/availability-date-selection.ts +16 -0
- package/src/components/booking/booking-flow-types.ts +4 -0
- package/src/components/booking/booking-flow.css +5 -0
- package/src/components/booking/change-booking-error-message.ts +137 -0
- package/src/components/booking/change-booking-flow-helpers.ts +77 -2
- package/src/components/booking/change-booking-quote-guards.ts +4 -1
- package/src/components/booking/change-booking-quote-state.ts +44 -1
- package/src/components/booking/incompatible-add-on-selections.ts +19 -0
- package/src/components/booking/private-shuttle-availability.ts +14 -0
- package/src/components/booking/private-shuttle-cancellation-policy.ts +9 -0
- package/src/components/booking/private-shuttle-checkout-controller.ts +5 -0
- package/src/components/booking/private-shuttle-provider-change-runner.ts +21 -0
- package/src/components/booking/private-shuttle-reservation-runner.ts +5 -3
- package/src/components/booking/provider-dashboard-change-booking.ts +18 -2
- package/src/components/booking/use-standard-booking-availability.ts +5 -2
- package/src/components/booking/useAdminChangeCheckoutController.ts +56 -1
- package/src/components/booking/useAdminChangeProductReset.ts +6 -0
- package/src/components/booking/useAdminChangeProtectedPricing.ts +4 -0
- package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +8 -6
- package/src/components/booking/useAdminChangeQuotePreview.ts +146 -61
- package/src/components/booking/useAdminCustomReceiptLines.ts +171 -10
- package/src/components/booking/useAdminProviderPricingAdjustments.ts +50 -19
- package/src/components/booking/useBookingAvailabilityAddOns.ts +26 -4
- package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
- package/src/components/booking/useChangeBookingAddOnFloorController.ts +15 -9
- package/src/components/booking/useChangeBookingAutoSelections.ts +60 -17
- package/src/components/booking/useChangeBookingInitialHydration.ts +10 -7
- package/src/components/booking/useChangeBookingQuotePreview.ts +3 -1
- package/src/components/booking/useChangeBookingSelectionDetails.ts +23 -17
- package/src/components/booking/useStandardBookingAutoSelections.ts +55 -7
- package/src/lib/booking/booking-cutoffs.ts +22 -0
- package/src/lib/booking/change-booking-server-preview.ts +92 -5
- package/src/lib/booking/change-flow-pricing.ts +12 -0
- package/src/lib/booking/i18n/messages/en.json +1 -0
- package/src/lib/booking/i18n/messages/fr.json +1 -0
- package/src/lib/booking-api.ts +76 -1
- package/test/change-booking-helpers.test.ts +1319 -7
- 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 {
|
|
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 {
|
|
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
|
-
|
|
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: {
|
|
313
|
-
|
|
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
|
+
});
|