@ticketboothapp/booking 1.2.185 → 1.2.187

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.185",
3
+ "version": "1.2.187",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -197,6 +197,8 @@ const checkoutPanelPropKeys = [
197
197
  'onRemoveAdminCustomReceiptLine',
198
198
  'onApplyAdminCustomReceiptLines',
199
199
  'onSetAdminReceiptLineOverride',
200
+ 'preserveExistingReceipt',
201
+ 'onPreserveExistingReceiptChange',
200
202
  'firstName',
201
203
  'lastName',
202
204
  'email',
@@ -348,6 +348,7 @@ export function AdminChangeBookingFlow({
348
348
  >({});
349
349
  const [changeQuoteLoading, setChangeQuoteLoading] = useState(false);
350
350
  const [changeQuoteFetchError, setChangeQuoteFetchError] = useState<string | null>(null);
351
+ const [preserveExistingReceipt, setPreserveExistingReceipt] = useState(false);
351
352
  /** Dedupe quote calls while user input payload is unchanged. */
352
353
  const lastCompletedQuoteInputsKeyRef = useRef<string | null>(null);
353
354
  const inFlightQuoteInputsKeyRef = useRef<string | null>(null);
@@ -804,6 +805,7 @@ export function AdminChangeBookingFlow({
804
805
  adminCustomReceiptLines: appliedAdminCustomReceiptLines,
805
806
  structuredAdjustments: adminStructuredAdjustments,
806
807
  reverseAdjustmentIds: adminReverseAdjustmentIds,
808
+ preserveExistingReceipt,
807
809
  useAdminFeAuthoritativeQuote,
808
810
  }), [
809
811
  initialValues?.bookingReference,
@@ -820,6 +822,7 @@ export function AdminChangeBookingFlow({
820
822
  appliedAdminCustomReceiptLines,
821
823
  adminStructuredAdjustments,
822
824
  adminReverseAdjustmentIds,
825
+ preserveExistingReceipt,
823
826
  useAdminFeAuthoritativeQuote,
824
827
  ]);
825
828
  const responseRefundDecisionOperations = latestChangeQuote?.refundDecisionOperations ?? [];
@@ -931,6 +934,7 @@ export function AdminChangeBookingFlow({
931
934
  adminCustomReceiptLines: appliedAdminCustomReceiptLines,
932
935
  adminStructuredAdjustments,
933
936
  adminReverseAdjustmentIds,
937
+ preserveExistingReceipt,
934
938
  needsAdjustmentDiscovery:
935
939
  isProviderDashboardChange && !hasHydratedExistingAdjustments,
936
940
  refundDispositionsByOperationId: activeRefundDispositionsByOperationId,
@@ -1153,6 +1157,7 @@ export function AdminChangeBookingFlow({
1153
1157
  mergedProviderAdditionalAdjustments,
1154
1158
  adminStructuredAdjustments,
1155
1159
  adminReverseAdjustmentIds,
1160
+ preserveExistingReceipt,
1156
1161
  refundDispositionsByOperationId: activeRefundDispositionsByOperationId,
1157
1162
  providerApplyAuthoritativeReceipt,
1158
1163
  onSuccess,
@@ -1301,6 +1306,8 @@ export function AdminChangeBookingFlow({
1301
1306
  onRemoveAdminCustomReceiptLine: handleRemoveAdminCustomReceiptLine,
1302
1307
  onApplyAdminCustomReceiptLines: handleApplyAdminCustomReceiptLines,
1303
1308
  onSetAdminReceiptLineOverride: handleSetAdminReceiptLineOverride,
1309
+ preserveExistingReceipt,
1310
+ onPreserveExistingReceiptChange: setPreserveExistingReceipt,
1304
1311
  onFirstNameChange: setFirstName, onLastNameChange: setLastName, onEmailChange: setEmail,
1305
1312
  onClearError: () => setError(''),
1306
1313
  pickupLocations: product.pickupLocations, destinations: product.destinations, highlightedPickupLocationIds,
@@ -87,6 +87,8 @@ export interface AdminChangeCheckoutPanelProps {
87
87
  baseAmount: number;
88
88
  targetAmount: number;
89
89
  }) => void;
90
+ preserveExistingReceipt: boolean;
91
+ onPreserveExistingReceiptChange: (preserve: boolean) => void;
90
92
  firstName: string;
91
93
  lastName: string;
92
94
  email: string;
@@ -179,6 +181,8 @@ export function AdminChangeCheckoutPanel({
179
181
  onRemoveAdminCustomReceiptLine,
180
182
  onApplyAdminCustomReceiptLines,
181
183
  onSetAdminReceiptLineOverride,
184
+ preserveExistingReceipt,
185
+ onPreserveExistingReceiptChange,
182
186
  firstName,
183
187
  lastName,
184
188
  email,
@@ -282,6 +286,8 @@ export function AdminChangeCheckoutPanel({
282
286
  promoEditor={promoEditor}
283
287
  promoCode={promoCode}
284
288
  adjustments={pricingAdjustments}
289
+ preserveExistingReceipt={preserveExistingReceipt}
290
+ onPreserveExistingReceiptChange={onPreserveExistingReceiptChange}
285
291
  adminCustomReceiptLines={adminCustomReceiptLines}
286
292
  onSetReceiptLineOverride={onSetAdminReceiptLineOverride}
287
293
  />
@@ -1,4 +1,4 @@
1
- import { useState, type ReactNode } from 'react';
1
+ import { useEffect, useState, type ReactNode } from 'react';
2
2
  import type {
3
3
  AdminAmendmentOperationSnapshot,
4
4
  AdminAmendmentRefundDisposition,
@@ -72,6 +72,8 @@ export interface AdminChangeReceiptComparisonProps {
72
72
  baseAmount: number;
73
73
  targetAmount: number;
74
74
  }) => void;
75
+ preserveExistingReceipt?: boolean;
76
+ onPreserveExistingReceiptChange?: (preserve: boolean) => void;
75
77
  }
76
78
 
77
79
  function normalizeLocale(locale: string): Locale {
@@ -128,8 +130,13 @@ export function AdminChangeReceiptComparison({
128
130
  refundQuoteLoading = false,
129
131
  adminCustomReceiptLines = [],
130
132
  onSetReceiptLineOverride,
133
+ preserveExistingReceipt = false,
134
+ onPreserveExistingReceiptChange,
131
135
  }: AdminChangeReceiptComparisonProps) {
132
136
  const [overrideDrafts, setOverrideDrafts] = useState<Record<string, string>>({});
137
+ useEffect(() => {
138
+ if (preserveExistingReceipt) setOverrideDrafts({});
139
+ }, [preserveExistingReceipt]);
133
140
  const readableQuoteError = friendlyChangeBookingError(quoteError);
134
141
  const displayLocale = normalizeLocale(locale);
135
142
  const existingLines = originalLines(originalReceipt);
@@ -222,7 +229,7 @@ export function AdminChangeReceiptComparison({
222
229
  line.overrideComponentId ? [[line.overrideComponentId, line] as const] : [],
223
230
  ),
224
231
  );
225
- const summaryDisplayLines = rawSummaryDisplayLines
232
+ const allSummaryDisplayLines = rawSummaryDisplayLines
226
233
  .filter((line) => !(line.kind === 'line' && line.label.startsWith('Override · ')))
227
234
  .map((line) => {
228
235
  const type = line.kind === 'ticket' ? 'TICKET' : String(line.type ?? '').toUpperCase();
@@ -235,6 +242,18 @@ export function AdminChangeReceiptComparison({
235
242
  ),
236
243
  };
237
244
  });
245
+ const isRemovedSummaryLine = (line: PriceSummaryLine) => {
246
+ if (!line.editable || !line.componentId) return false;
247
+ const override = overrideByComponentId.get(line.componentId);
248
+ if (!override) return false;
249
+ const baseAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
250
+ const targetAmount = baseAmount + override.amountSign * Number(override.amountInput);
251
+ return Number.isFinite(targetAmount) && Math.abs(targetAmount) < 0.005;
252
+ };
253
+ const summaryDisplayLines = allSummaryDisplayLines.filter(
254
+ (line) => !isRemovedSummaryLine(line),
255
+ );
256
+ const removedSummaryDisplayLines = allSummaryDisplayLines.filter(isRemovedSummaryLine);
238
257
  const quotedOverrideAmountInputs = Object.fromEntries(
239
258
  summaryDisplayLines.flatMap((line) => {
240
259
  if (!line.componentId || !line.lineKey) return [];
@@ -267,6 +286,34 @@ export function AdminChangeReceiptComparison({
267
286
  return next;
268
287
  });
269
288
  };
289
+ const handleOverrideRemove = (lineKey: string) => {
290
+ const line = summaryDisplayLines.find((candidate) => candidate.lineKey === lineKey);
291
+ if (!line?.componentId) return;
292
+ const baseAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
293
+ onSetReceiptLineOverride?.({
294
+ componentId: line.componentId,
295
+ lineKey,
296
+ label: line.kind === 'ticket' ? line.category : line.label,
297
+ baseAmount,
298
+ targetAmount: 0,
299
+ });
300
+ setOverrideDrafts((current) => {
301
+ const next = { ...current };
302
+ delete next[lineKey];
303
+ return next;
304
+ });
305
+ };
306
+ const handleOverrideRestore = (line: PriceSummaryLine) => {
307
+ if (!line.componentId || !line.lineKey) return;
308
+ const baseAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
309
+ onSetReceiptLineOverride?.({
310
+ componentId: line.componentId,
311
+ lineKey: line.lineKey,
312
+ label: line.kind === 'ticket' ? line.category : line.label,
313
+ baseAmount,
314
+ targetAmount: baseAmount,
315
+ });
316
+ };
270
317
  const summarySubtotal = isProductFamilyConversion
271
318
  ? familyConversionReceiptLines.reduce((sum, line) => {
272
319
  if (line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX') return sum;
@@ -327,15 +374,38 @@ export function AdminChangeReceiptComparison({
327
374
  </p>
328
375
  ) : null}
329
376
  </div>
330
- <span className="shrink-0 text-xs font-medium text-stone-500">
377
+ <div className="flex shrink-0 items-center gap-2">
378
+ {onPreserveExistingReceiptChange ? (
379
+ <button
380
+ type="button"
381
+ aria-pressed={preserveExistingReceipt}
382
+ className={`rounded-md border px-2.5 py-1.5 text-xs font-semibold transition ${
383
+ preserveExistingReceipt
384
+ ? 'border-sky-700 bg-sky-700 text-white'
385
+ : 'border-stone-300 bg-white text-stone-700 hover:border-sky-500 hover:text-sky-800'
386
+ }`}
387
+ onClick={() => onPreserveExistingReceiptChange(!preserveExistingReceipt)}
388
+ >
389
+ Keep receipt unchanged
390
+ </button>
391
+ ) : null}
392
+ <span className="text-xs font-medium text-stone-500">
331
393
  {isProductFamilyConversion
332
394
  ? formatCurrencyAmount(updatedTotal ?? 0, currency, displayLocale)
333
395
  : bookingTotalChange == null
334
396
  ? '—'
335
397
  : formatCurrencyAmount(bookingTotalChange, currency, displayLocale)}
336
- </span>
398
+ </span>
399
+ </div>
337
400
  </div>
338
- {promoEditor}
401
+ {preserveExistingReceipt ? (
402
+ <div className="mb-3 rounded-md border border-sky-200 bg-white px-3 py-2">
403
+ <p className="text-sm font-semibold text-stone-800">Receipt and balance will stay unchanged</p>
404
+ <p className="mt-0.5 text-xs text-stone-600">
405
+ Only the booking details will change. No charge, refund, or balance adjustment will be created.
406
+ </p>
407
+ </div>
408
+ ) : promoEditor}
339
409
  {!selectionChanged ? (
340
410
  <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
341
411
  <p className="text-sm font-medium text-stone-700">No changes selected</p>
@@ -353,37 +423,60 @@ export function AdminChangeReceiptComparison({
353
423
  <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
354
424
  <p className="text-sm font-medium text-stone-700">Loading price change…</p>
355
425
  </div>
356
- ) : summaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 && removedWithoutRefund > 0 ? (
426
+ ) : summaryDisplayLines.length === 0 && removedSummaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 && removedWithoutRefund > 0 ? (
357
427
  <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
358
428
  <p className="text-sm font-medium text-stone-800">No refund will be created</p>
359
429
  <p className="mt-1 text-xs text-stone-600">
360
430
  {formatCurrencyAmount(removedWithoutRefund, currency, displayLocale)} of booking value will be removed without customer credit.
361
431
  </p>
362
432
  </div>
363
- ) : summaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 ? (
433
+ ) : summaryDisplayLines.length === 0 && removedSummaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 ? (
364
434
  <div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
365
435
  <p className="text-sm font-medium text-stone-700">No pricing changes</p>
366
436
  </div>
367
437
  ) : (
368
- <PriceSummary
369
- lines={summaryDisplayLines}
370
- total={summaryTotal ?? amountDue}
371
- totalLabel={summaryTotalLabel}
372
- showPositiveTotalSign={!isProductFamilyConversion}
373
- currency={currency}
374
- locale={displayLocale}
375
- subtotal={summarySubtotal}
376
- size="sm"
377
- t={t}
378
- lineAmountInputs={{ ...quotedOverrideAmountInputs, ...overrideDrafts }}
379
- onLineAmountInputChange={handleOverrideChange}
380
- onLineAmountInputBlur={handleOverrideBlur}
381
- />
438
+ <div>
439
+ <PriceSummary
440
+ lines={summaryDisplayLines}
441
+ total={summaryTotal ?? amountDue}
442
+ totalLabel={summaryTotalLabel}
443
+ showPositiveTotalSign={!isProductFamilyConversion}
444
+ currency={currency}
445
+ locale={displayLocale}
446
+ subtotal={summarySubtotal}
447
+ size="sm"
448
+ t={t}
449
+ lineAmountInputs={preserveExistingReceipt ? undefined : { ...quotedOverrideAmountInputs, ...overrideDrafts }}
450
+ onLineAmountInputChange={preserveExistingReceipt ? undefined : handleOverrideChange}
451
+ onLineAmountInputBlur={preserveExistingReceipt ? undefined : handleOverrideBlur}
452
+ onLineRemove={preserveExistingReceipt ? undefined : handleOverrideRemove}
453
+ />
454
+ {removedSummaryDisplayLines.length > 0 ? (
455
+ <div className="mt-3 border-t border-stone-200 pt-2 text-xs text-stone-500">
456
+ <span className="font-medium text-stone-600">Removed lines:</span>{' '}
457
+ {removedSummaryDisplayLines.map((line, index) => {
458
+ const label = line.kind === 'ticket' ? line.category : line.label;
459
+ return (
460
+ <span key={line.lineKey ?? `${label}-${index}`}>
461
+ {index > 0 ? ', ' : ''}{label}{' '}
462
+ <button
463
+ type="button"
464
+ className="font-medium text-stone-700 underline underline-offset-2 hover:text-stone-950"
465
+ onClick={() => handleOverrideRestore(line)}
466
+ >
467
+ Undo
468
+ </button>
469
+ </span>
470
+ );
471
+ })}
472
+ </div>
473
+ ) : null}
474
+ </div>
382
475
  )}
383
476
  </section>
384
477
  </div>
385
478
 
386
- {adjustments ? (
479
+ {!preserveExistingReceipt && adjustments ? (
387
480
  <section className="rounded-lg border border-amber-200 bg-amber-50/40 p-3">
388
481
  <h3 className="mb-2 text-sm font-semibold text-stone-900">Add custom line items</h3>
389
482
  {adjustments}
@@ -28,6 +28,7 @@ export interface PriceBreakdownProps {
28
28
  onEditableChange?: (value: string) => void;
29
29
  onEditableBlur?: () => void;
30
30
  onEditableReset?: () => void;
31
+ onEditableRemove?: () => void;
31
32
  editableLabelValue?: string;
32
33
  onEditableLabelChange?: (value: string) => void;
33
34
  }
@@ -76,6 +77,7 @@ export function PriceBreakdown({
76
77
  onEditableChange,
77
78
  onEditableBlur,
78
79
  onEditableReset,
80
+ onEditableRemove,
79
81
  editableLabelValue,
80
82
  onEditableLabelChange,
81
83
  }: PriceBreakdownProps) {
@@ -129,6 +131,17 @@ export function PriceBreakdown({
129
131
  onChange={(e) => onEditableChange(e.target.value)}
130
132
  onBlur={onEditableBlur}
131
133
  />
134
+ {onEditableRemove ? (
135
+ <button
136
+ type="button"
137
+ className="rounded p-0.5 text-stone-400 hover:bg-red-50 hover:text-red-600"
138
+ onClick={onEditableRemove}
139
+ aria-label={`Remove ${category} line`}
140
+ title="Remove line"
141
+ >
142
+ <span className="inline-block h-4 w-4 text-center text-base leading-4">×</span>
143
+ </button>
144
+ ) : null}
132
145
  </div>
133
146
  ) : (
134
147
  <span className="text-sm font-medium text-stone-700">
@@ -191,6 +204,17 @@ export function PriceBreakdown({
191
204
  onChange={(e) => onEditableChange(e.target.value)}
192
205
  onBlur={onEditableBlur}
193
206
  />
207
+ {onEditableRemove ? (
208
+ <button
209
+ type="button"
210
+ className="rounded p-0.5 text-stone-400 hover:bg-red-50 hover:text-red-600"
211
+ onClick={onEditableRemove}
212
+ aria-label={`Remove ${category} line`}
213
+ title="Remove line"
214
+ >
215
+ <span className="inline-block h-4 w-4 text-center text-base leading-4">×</span>
216
+ </button>
217
+ ) : null}
194
218
  </div>
195
219
  ) : (
196
220
  <span
@@ -98,6 +98,8 @@ export interface PriceSummaryProps {
98
98
  onLineAmountInputBlur?: (lineKey: string) => void;
99
99
  /** Optional reset handler for inline editable line amounts. */
100
100
  onLineAmountReset?: (lineKey: string) => void;
101
+ /** Optional remove handler for inline editable lines. */
102
+ onLineRemove?: (lineKey: string) => void;
101
103
  }
102
104
 
103
105
  function getLineAmountClass(type: string | undefined, amount: number): string {
@@ -174,7 +176,6 @@ export function PriceSummary({
174
176
  locale,
175
177
  subtotal,
176
178
  taxAmount = 0,
177
- taxRate: _taxRate,
178
179
  discountAmount = 0,
179
180
  discountLabel,
180
181
  size = 'sm',
@@ -194,6 +195,7 @@ export function PriceSummary({
194
195
  onLineLabelInputChange,
195
196
  onLineAmountInputBlur,
196
197
  onLineAmountReset,
198
+ onLineRemove,
197
199
  }: PriceSummaryProps) {
198
200
  const textSize = size === 'sm' ? 'text-sm' : 'text-base';
199
201
  const totalSize = size === 'sm' ? 'text-xl' : 'text-2xl';
@@ -247,6 +249,11 @@ export function PriceSummary({
247
249
  ? () => onLineAmountReset(row.lineKey!)
248
250
  : undefined
249
251
  }
252
+ onEditableRemove={
253
+ row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineRemove
254
+ ? () => onLineRemove(row.lineKey!)
255
+ : undefined
256
+ }
250
257
  editableLabelValue={row.lineKey ? lineLabelInputs?.[row.lineKey] : undefined}
251
258
  onEditableLabelChange={
252
259
  row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineLabelInputChange
@@ -313,6 +320,17 @@ export function PriceSummary({
313
320
  onChange={(e) => onLineAmountInputChange(lineKey, e.target.value)}
314
321
  onBlur={() => onLineAmountInputBlur?.(lineKey)}
315
322
  />
323
+ {isBeforeSubtotalBoundary && onLineRemove ? (
324
+ <button
325
+ type="button"
326
+ className="rounded p-0.5 text-stone-400 hover:bg-red-50 hover:text-red-600"
327
+ onClick={() => onLineRemove(lineKey)}
328
+ aria-label={`Remove ${label} line`}
329
+ title="Remove line"
330
+ >
331
+ <span className="inline-block h-4 w-4 text-center text-base leading-4">×</span>
332
+ </button>
333
+ ) : null}
316
334
  </div>
317
335
  ) : (
318
336
  <span className={`flex-shrink-0 whitespace-nowrap font-medium ${getLineAmountClass(type, amount)}`}>
@@ -40,6 +40,7 @@ export interface BuildAdminChangeProviderPayloadParams {
40
40
  mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
41
41
  adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
42
42
  adminReverseAdjustmentIds?: string[];
43
+ preserveExistingReceipt?: boolean;
43
44
  refundDispositionsByOperationId: NonNullable<
44
45
  ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
45
46
  >;
@@ -69,6 +70,7 @@ export function buildAdminChangeProviderPayload({
69
70
  mergedProviderAdditionalAdjustments,
70
71
  adminStructuredAdjustments,
71
72
  adminReverseAdjustmentIds = [],
73
+ preserveExistingReceipt = false,
72
74
  refundDispositionsByOperationId,
73
75
  pricingV2QuoteId,
74
76
  pricingV2QuotedTotal,
@@ -84,6 +86,7 @@ export function buildAdminChangeProviderPayload({
84
86
  : null;
85
87
 
86
88
  return {
89
+ preserveExistingReceipt,
87
90
  productId: availabilityProductOptionId,
88
91
  parentProductId: product.productId,
89
92
  availabilityId: selectedAvailability.availabilityId ?? null,
@@ -102,10 +105,11 @@ export function buildAdminChangeProviderPayload({
102
105
  cancellationPolicyId: cancellationPolicyId ?? initialCancellationPolicyId ?? null,
103
106
  promoCode: appliedPromoCode ?? null,
104
107
  promoApplicationScope: appliedPromoCode ? promoApplicationScope : null,
105
- newTotalAmount,
108
+ newTotalAmount: preserveExistingReceipt ? undefined : newTotalAmount,
106
109
  additionalHoursCount: null,
107
110
  pricingAdjustment:
108
- providerPricingOverrides.length > 0 || mergedProviderAdditionalAdjustments.length > 0
111
+ !preserveExistingReceipt &&
112
+ (providerPricingOverrides.length > 0 || mergedProviderAdditionalAdjustments.length > 0)
109
113
  ? {
110
114
  mode: 'MANUAL_LINES',
111
115
  ...(providerPricingOverrides.length > 0 ? { lineOverrides: providerPricingOverrides } : {}),
@@ -115,9 +119,9 @@ export function buildAdminChangeProviderPayload({
115
119
  }
116
120
  : undefined,
117
121
  authoritativeReceipt: providerApplyAuthoritativeReceipt,
118
- structuredAdjustments: adminStructuredAdjustments,
119
- reverseAdjustmentIds: adminReverseAdjustmentIds,
120
- refundDispositionsByOperationId,
122
+ structuredAdjustments: preserveExistingReceipt ? [] : adminStructuredAdjustments,
123
+ reverseAdjustmentIds: preserveExistingReceipt ? [] : adminReverseAdjustmentIds,
124
+ refundDispositionsByOperationId: preserveExistingReceipt ? {} : refundDispositionsByOperationId,
121
125
  pricingV2QuoteId: pricingV2QuoteId ?? null,
122
126
  pricingV2QuotedTotal: pricingV2QuotedTotal ?? null,
123
127
  capacitySeatCredit: {
@@ -16,6 +16,7 @@ interface AdminChangeQuoteRequestKeyInput {
16
16
  promoApplicationScope?: AdminPromoApplicationScope | null;
17
17
  structuredAdjustments?: unknown[];
18
18
  reverseAdjustmentIds?: string[];
19
+ preserveExistingReceipt?: boolean;
19
20
  refundDispositionsByOperationId?: Record<string, string>;
20
21
  useAdminFeAuthoritativeQuote: boolean;
21
22
  }
@@ -74,6 +75,7 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
74
75
  promoApplicationScope: input.promoCode ? (input.promoApplicationScope ?? 'CHANGE_ONLY') : null,
75
76
  structuredAdjustments: input.structuredAdjustments ?? [],
76
77
  reverseAdjustmentIds: [...(input.reverseAdjustmentIds ?? [])].sort(),
78
+ preserveExistingReceipt: input.preserveExistingReceipt === true,
77
79
  refundDispositionsByOperationId: Object.fromEntries(
78
80
  Object.entries(input.refundDispositionsByOperationId ?? {}).sort(([a], [b]) => a.localeCompare(b)),
79
81
  ),
@@ -32,6 +32,7 @@ export interface BuildAdminChangeV2QuoteRequestParams {
32
32
  promoApplicationScope?: AdminPromoApplicationScope;
33
33
  structuredAdjustments: NonNullable<ChangeBookingQuoteRequest['structuredAdjustments']>;
34
34
  reverseAdjustmentIds?: string[];
35
+ preserveExistingReceipt?: boolean;
35
36
  includeSelectableAdjustmentComponents?: boolean;
36
37
  refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
37
38
  previousPassengerCount: number;
@@ -59,6 +60,7 @@ export function buildAdminChangeV2QuoteRequest({
59
60
  promoApplicationScope = 'CHANGE_ONLY',
60
61
  structuredAdjustments,
61
62
  reverseAdjustmentIds = [],
63
+ preserveExistingReceipt = false,
62
64
  includeSelectableAdjustmentComponents = false,
63
65
  refundDispositionsByOperationId,
64
66
  previousPassengerCount,
@@ -80,12 +82,13 @@ export function buildAdminChangeV2QuoteRequest({
80
82
  cancellationPolicyId,
81
83
  promoCode: promoCode?.trim() || null,
82
84
  promoApplicationScope: promoCode?.trim() ? promoApplicationScope : null,
83
- structuredAdjustments,
84
- reverseAdjustmentIds,
85
+ structuredAdjustments: preserveExistingReceipt ? [] : structuredAdjustments,
86
+ reverseAdjustmentIds: preserveExistingReceipt ? [] : reverseAdjustmentIds,
87
+ preserveExistingReceipt,
85
88
  ...(includeSelectableAdjustmentComponents
86
89
  ? { includeSelectableAdjustmentComponents: true }
87
90
  : {}),
88
- ...(Object.keys(refundDispositionsByOperationId).length > 0
91
+ ...(!preserveExistingReceipt && Object.keys(refundDispositionsByOperationId).length > 0
89
92
  ? { refundDispositionsByOperationId }
90
93
  : {}),
91
94
  capacitySeatCredit: {
@@ -33,6 +33,8 @@ export type AdminAmendmentStructuredAdjustment = {
33
33
 
34
34
  /** Payload passed to `onChangeBooking` when applying a dashboard-managed booking change. */
35
35
  export type ProviderDashboardChangeBookingPayload = {
36
+ /** Apply operational changes while preserving the existing receipt and balance exactly. */
37
+ preserveExistingReceipt?: boolean;
36
38
  /** Parent catalog product id. Present when admin changes the booking to a different product. */
37
39
  parentProductId?: string;
38
40
  /** Product option id selected for the new booking. */
@@ -155,6 +155,7 @@ export interface UseAdminChangeCheckoutControllerParams {
155
155
  mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
156
156
  adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
157
157
  adminReverseAdjustmentIds: string[];
158
+ preserveExistingReceipt: boolean;
158
159
  refundDispositionsByOperationId: NonNullable<
159
160
  ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
160
161
  >;
@@ -258,6 +259,7 @@ export function useAdminChangeCheckoutController({
258
259
  mergedProviderAdditionalAdjustments,
259
260
  adminStructuredAdjustments,
260
261
  adminReverseAdjustmentIds,
262
+ preserveExistingReceipt,
261
263
  refundDispositionsByOperationId,
262
264
  providerApplyAuthoritativeReceipt,
263
265
  onSuccess,
@@ -381,6 +383,7 @@ export function useAdminChangeCheckoutController({
381
383
  mergedProviderAdditionalAdjustments,
382
384
  adminStructuredAdjustments,
383
385
  adminReverseAdjustmentIds,
386
+ preserveExistingReceipt,
384
387
  refundDispositionsByOperationId,
385
388
  pricingV2QuoteId: authoritativeQuote?.quoteId ?? null,
386
389
  pricingV2QuotedTotal: authoritativeQuote?.quotedTotal ?? authoritativeQuote?.serverDisplay?.total ?? null,
@@ -80,6 +80,7 @@ export interface UseAdminChangeQuotePreviewParams {
80
80
  adminCustomReceiptLines: AdminCustomReceiptLine[];
81
81
  adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
82
82
  adminReverseAdjustmentIds: string[];
83
+ preserveExistingReceipt: boolean;
83
84
  needsAdjustmentDiscovery: boolean;
84
85
  refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
85
86
  useAdminFeAuthoritativeQuote: boolean;
@@ -144,6 +145,7 @@ export function useAdminChangeQuotePreview({
144
145
  adminCustomReceiptLines,
145
146
  adminStructuredAdjustments,
146
147
  adminReverseAdjustmentIds,
148
+ preserveExistingReceipt,
147
149
  needsAdjustmentDiscovery,
148
150
  refundDispositionsByOperationId,
149
151
  useAdminFeAuthoritativeQuote,
@@ -238,6 +240,7 @@ export function useAdminChangeQuotePreview({
238
240
  promoApplicationScope,
239
241
  structuredAdjustments: adminStructuredAdjustments,
240
242
  reverseAdjustmentIds: adminReverseAdjustmentIds,
243
+ preserveExistingReceipt,
241
244
  refundDispositionsByOperationId,
242
245
  useAdminFeAuthoritativeQuote,
243
246
  }), [
@@ -255,6 +258,7 @@ export function useAdminChangeQuotePreview({
255
258
  promoApplicationScope,
256
259
  adminStructuredAdjustments,
257
260
  adminReverseAdjustmentIds,
261
+ preserveExistingReceipt,
258
262
  refundDispositionsByOperationId,
259
263
  useAdminFeAuthoritativeQuote,
260
264
  ]);
@@ -526,6 +530,7 @@ export function useAdminChangeQuotePreview({
526
530
  promoApplicationScope,
527
531
  structuredAdjustments: adminStructuredAdjustments,
528
532
  reverseAdjustmentIds: adminReverseAdjustmentIds,
533
+ preserveExistingReceipt,
529
534
  includeSelectableAdjustmentComponents:
530
535
  needsAdjustmentDiscovery || adminCustomReceiptLines.some(
531
536
  (line) => line.scope === 'SELECTED_COMPONENTS',
@@ -43,6 +43,10 @@ export function lineFromActiveAdjustment(
43
43
  replacementAdjustmentId: string,
44
44
  ): AdminCustomReceiptLine {
45
45
  const percentage = adjustment.mode !== 'FIXED_AMOUNT';
46
+ const overrideComponentId =
47
+ adjustment.reasonCode === 'LINE_ITEM_OVERRIDE' && adjustment.affectedComponentIds.length === 1
48
+ ? adjustment.affectedComponentIds[0]
49
+ : undefined;
46
50
  return {
47
51
  id: `existing:${adjustment.adjustmentId}`,
48
52
  label:
@@ -62,6 +66,7 @@ export function lineFromActiveAdjustment(
62
66
  sourceAdjustmentId: adjustment.adjustmentId,
63
67
  replacementAdjustmentId,
64
68
  isDirty: false,
69
+ overrideComponentId,
65
70
  };
66
71
  }
67
72
 
@@ -160,6 +165,8 @@ export function useAdminCustomReceiptLines(
160
165
  const existing = adminCustomReceiptLines.find(
161
166
  (line) => line.overrideComponentId === input.componentId,
162
167
  );
168
+ const sourceAdjustmentId = existing?.sourceAdjustmentId;
169
+ const restoringQuotedLine = Math.abs(delta) < 0.005;
163
170
  const next = Math.abs(delta) < 0.005
164
171
  ? adminCustomReceiptLines.filter((line) => line.id !== existing?.id)
165
172
  : [
@@ -177,8 +184,22 @@ export function useAdminCustomReceiptLines(
177
184
  overrideComponentId: input.componentId,
178
185
  overrideBaseAmount: input.baseAmount,
179
186
  overrideLineKey: input.lineKey,
187
+ ...(existing?.sourceAdjustmentId
188
+ ? {
189
+ sourceAdjustmentId: existing.sourceAdjustmentId,
190
+ replacementAdjustmentId: existing.replacementAdjustmentId,
191
+ isDirty: true,
192
+ }
193
+ : {}),
180
194
  },
181
195
  ];
196
+ if (sourceAdjustmentId) {
197
+ const updateRemovedIds = (current: string[]) => restoringQuotedLine
198
+ ? Array.from(new Set([...current, sourceAdjustmentId]))
199
+ : current.filter((id) => id !== sourceAdjustmentId);
200
+ setRemovedExistingAdjustmentIds(updateRemovedIds);
201
+ setAppliedRemovedExistingAdjustmentIds(updateRemovedIds);
202
+ }
182
203
  setAdminCustomReceiptLines(next);
183
204
  setAppliedAdminCustomReceiptLines(next);
184
205
  }, [adminCustomReceiptLines, nextDraftId]);
@@ -1475,6 +1475,8 @@ export interface ChangeBookingQuoteRequest {
1475
1475
  }>;
1476
1476
  /** Existing immutable adjustment ids to reverse as part of this amendment. */
1477
1477
  reverseAdjustmentIds?: string[];
1478
+ /** Admin only: change operations without changing the receipt or outstanding balance. */
1479
+ preserveExistingReceipt?: boolean;
1478
1480
  }
1479
1481
 
1480
1482
  /** FE-authored receipt payload for admin quote path (major units, booking currency). */
@@ -234,6 +234,31 @@ test('existing admin adjustment hydrates for immutable reverse-and-replace editi
234
234
  assert.deepEqual(reverseAdjustmentIdsForAdminCustomLines([], ['admin-rcpt-1']), ['admin-rcpt-1']);
235
235
  });
236
236
 
237
+ test('saved line-item override hydrates its targeted receipt component', () => {
238
+ const line = lineFromActiveAdjustment({
239
+ adjustmentId: 'override-adult-1',
240
+ operationId: 'op-override-adult-1',
241
+ mode: 'FIXED_AMOUNT',
242
+ scope: 'SELECTED_COMPONENTS',
243
+ basisCents: 19_799,
244
+ amountCents: 19_799,
245
+ taxDeltaCents: 990,
246
+ affectedComponentIds: ['adult-component'],
247
+ taxBehavior: 'TAXABLE',
248
+ reasonCode: 'LINE_ITEM_OVERRIDE',
249
+ reason: 'Override · Refund · ADULT',
250
+ createdAt: '2026-08-09T00:00:00Z',
251
+ }, 'replacement-override-adult-1');
252
+
253
+ assert.equal(line.overrideComponentId, 'adult-component');
254
+ assert.deepEqual(line.selectedComponentIds, ['adult-component']);
255
+ assert.equal(line.sourceAdjustmentId, 'override-adult-1');
256
+ assert.deepEqual(
257
+ reverseAdjustmentIdsForAdminCustomLines([{ ...line, isDirty: true }]),
258
+ ['override-adult-1'],
259
+ );
260
+ });
261
+
237
262
  test('private resource quantity is not carried into a regular-tour conversion', () => {
238
263
  const quantities = deriveCompatibleInitialBookingQuantities(
239
264
  [{ category: 'RESOURCE', count: 1 }],
@@ -783,6 +808,38 @@ test('admin preview and checkout share the Pricing V2 request shape', () => {
783
808
  assert.equal('clientProposedTotal' in request, false);
784
809
  });
785
810
 
811
+ test('preserve-receipt quote is a first-class backend mode and excludes price edits', () => {
812
+ const request = buildAdminChangeV2QuoteRequest({
813
+ bookingReference: 'VFSCAEKQ',
814
+ lastName: 'Tauro',
815
+ parentProductId: 'product_1',
816
+ optionId: 'option_2',
817
+ selectedAvailability: {
818
+ availabilityId: 'availability_2',
819
+ productId: 'option_2',
820
+ dateTime: '2026-07-29T08:35:00-06:00',
821
+ vacancies: 10,
822
+ } as Parameters<typeof buildAdminChangeV2QuoteRequest>[0]['selectedAvailability'],
823
+ pickupLocationId: 'pickup_1',
824
+ returnAvailabilityId: null,
825
+ bookingItems: [{ category: 'ADULT', count: 2 }],
826
+ addOnSelections: [],
827
+ cancellationPolicyId: 'policy_1',
828
+ structuredAdjustments: [{ adjustmentId: 'ignored_draft' }],
829
+ reverseAdjustmentIds: ['ignored_reversal'],
830
+ preserveExistingReceipt: true,
831
+ refundDispositionsByOperationId: { ignored_removal: 'PENDING_REFUND' },
832
+ previousPassengerCount: 2,
833
+ previousAvailabilityId: 'availability_1',
834
+ previousReturnAvailabilityId: null,
835
+ });
836
+
837
+ assert.equal(request.preserveExistingReceipt, true);
838
+ assert.deepEqual(request.structuredAdjustments, []);
839
+ assert.deepEqual(request.reverseAdjustmentIds, []);
840
+ assert.equal(request.refundDispositionsByOperationId, undefined);
841
+ });
842
+
786
843
  test('admin Pricing V2 quote mapper preserves the server quote identity', async () => {
787
844
  const request = buildAdminChangeV2QuoteRequest({
788
845
  bookingReference: 'VFSCAEKQ',
@@ -909,6 +966,49 @@ test('admin no-charge provider payload carries the selected pickup itinerary', (
909
966
  assert.equal(payload?.promoApplicationScope, null);
910
967
  });
911
968
 
969
+ test('preserve-receipt provider payload carries the mode without price mutations', () => {
970
+ const payload = buildAdminChangeProviderPayload({
971
+ selectedAvailability: {
972
+ availabilityId: 'availability_2',
973
+ productId: 'option_2',
974
+ dateTime: '2026-07-29T08:35:00-06:00',
975
+ vacancies: 10,
976
+ } as Parameters<typeof buildAdminChangeProviderPayload>[0]['selectedAvailability'],
977
+ availabilityProductOptionId: 'option_2',
978
+ bookingItems: [{ category: 'ADULT', count: 2 }],
979
+ product: { productId: 'product_1', name: 'Changed tour' } as Parameters<
980
+ typeof buildAdminChangeProviderPayload
981
+ >[0]['product'],
982
+ pickupLocationId: null,
983
+ selectedReturnOption: null,
984
+ addOnSelections: [],
985
+ cancellationPolicyId: null,
986
+ appliedPromoCode: null,
987
+ newTotalAmount: 999,
988
+ providerPricingOverrides: [{ lineKey: 'ticket', amount: 999 }],
989
+ mergedProviderAdditionalAdjustments: [{ label: 'Draft fee', amount: 10 }],
990
+ adminStructuredAdjustments: [{
991
+ adjustmentId: 'draft',
992
+ mode: 'FIXED_AMOUNT',
993
+ fixedAmount: 10,
994
+ scope: 'CURRENT_ACTIVE_BOOKING',
995
+ taxBehavior: 'NON_TAXABLE',
996
+ reasonCode: 'STAFF_ADJUSTMENT',
997
+ }],
998
+ adminReverseAdjustmentIds: ['old_adjustment'],
999
+ preserveExistingReceipt: true,
1000
+ refundDispositionsByOperationId: { removal: 'PENDING_REFUND' },
1001
+ previousPassengerCount: 2,
1002
+ });
1003
+
1004
+ assert.equal(payload?.preserveExistingReceipt, true);
1005
+ assert.equal(payload?.newTotalAmount, undefined);
1006
+ assert.equal(payload?.pricingAdjustment, undefined);
1007
+ assert.deepEqual(payload?.structuredAdjustments, []);
1008
+ assert.deepEqual(payload?.reverseAdjustmentIds, []);
1009
+ assert.deepEqual(payload?.refundDispositionsByOperationId, {});
1010
+ });
1011
+
912
1012
  test('public paid change preview shows amendment-only charges before combined tax', () => {
913
1013
  const summary = buildPublicChangeAmountDueSummary([
914
1014
  {