@ticketboothapp/booking 1.2.153 → 1.2.155

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.153",
3
+ "version": "1.2.155",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -56,7 +56,10 @@ import { useAdminChangeProductReset } from './useAdminChangeProductReset';
56
56
  import { useBookingQuantityCapTrim } from './useBookingQuantityCapTrim';
57
57
  import { useBookingViewItemAnalytics } from './useBookingViewItemAnalytics';
58
58
  import { buildAdminChangeQuoteRequestKey } from './admin-change-quote-request-key';
59
- import type { AdminReleaseRefundDisposition } from './admin-refund-disposition';
59
+ import {
60
+ buildDefaultRefundDispositionsByOperationId,
61
+ type AdminReleaseRefundDisposition,
62
+ } from './admin-refund-disposition';
60
63
 
61
64
  type AdminRefundDecisionContext = {
62
65
  selectionKey: string;
@@ -730,6 +733,39 @@ export function AdminChangeBookingFlow({
730
733
  : refundDecisionContext?.selectionKey === refundDecisionSelectionKey
731
734
  ? refundDecisionContext
732
735
  : null;
736
+ /** Seed a disposition once per booking-selection key; operator can still override the dropdown. */
737
+ const refundDispositionAutoDefaultKeyRef = useRef<string | null>(null);
738
+ useEffect(() => {
739
+ if (!activeRefundDecisionContext || activeRefundDecisionContext.operations.length === 0) return;
740
+ if (refundDispositionAutoDefaultKeyRef.current === activeRefundDecisionContext.selectionKey) {
741
+ return;
742
+ }
743
+ refundDispositionAutoDefaultKeyRef.current = activeRefundDecisionContext.selectionKey;
744
+
745
+ const sameProductAndOutboundSelection =
746
+ !changeSelectionDetails.dateChanged &&
747
+ !changeSelectionDetails.productChanged &&
748
+ !changeSelectionDetails.optionChanged &&
749
+ !changeSelectionDetails.countsChanged &&
750
+ !changeSelectionDetails.addOnsChanged;
751
+ const defaults = buildDefaultRefundDispositionsByOperationId({
752
+ operations: activeRefundDecisionContext.operations,
753
+ allowedByOperationId: activeRefundDecisionContext.allowedByOperationId,
754
+ sameProductAndOutboundSelection,
755
+ });
756
+ if (Object.keys(defaults).length === 0) return;
757
+
758
+ setRefundDispositionsByOperationId((current) => {
759
+ let changed = false;
760
+ const next = { ...current };
761
+ for (const [operationId, disposition] of Object.entries(defaults)) {
762
+ if (next[operationId] != null) continue;
763
+ next[operationId] = disposition;
764
+ changed = true;
765
+ }
766
+ return changed ? next : current;
767
+ });
768
+ }, [activeRefundDecisionContext, changeSelectionDetails]);
733
769
  const activeRefundDispositionsByOperationId = useMemo(() => Object.fromEntries(
734
770
  (activeRefundDecisionContext?.operations ?? []).flatMap((operation) => {
735
771
  const disposition = refundDispositionsByOperationId[operation.operationId];
@@ -1058,7 +1094,11 @@ export function AdminChangeBookingFlow({
1058
1094
  serverAmendmentLines: latestChangeQuote?.serverPreview?.priceSummaryLines ?? null,
1059
1095
  serverAmountDue: latestChangeQuote?.serverPreview?.amountDue ?? null,
1060
1096
  serverUpdatedTotal: latestChangeQuote?.serverPreview?.totalNewBooking ?? null,
1061
- serverQuoteError: changeQuoteFetchError,
1097
+ serverQuoteError:
1098
+ changeQuoteFetchError ??
1099
+ (isChangeQuoteBlocked
1100
+ ? (latestChangeQuote?.reasonIfBlocked ?? 'This change cannot be priced right now.')
1101
+ : null),
1062
1102
  refundDecisionOperations: activeRefundDecisionContext?.operations ?? [],
1063
1103
  allowedRefundDispositionsByOperationId:
1064
1104
  activeRefundDecisionContext?.allowedByOperationId ?? {},
@@ -155,7 +155,7 @@ export function AdminChangeReceiptComparison({
155
155
  <div className="mb-3">
156
156
  <h3 className="text-sm font-semibold text-stone-900">Choose refund treatment</h3>
157
157
  <p className="mt-1 text-xs text-stone-600">
158
- Removed paid value is never refunded automatically. Choose an explicit treatment, then the server will recalculate the settlement.
158
+ Removed paid value is never refunded automatically. Choose an explicit treatment (return-only changes default to no refund), then the server will recalculate the settlement.
159
159
  </p>
160
160
  </div>
161
161
  <div className="space-y-3">
@@ -27,6 +27,9 @@ const RELEASE_OPTIONS: AdminRefundDispositionOption[] = [
27
27
  },
28
28
  ];
29
29
 
30
+ /** Server ops whose removed value is only a return-time substitution. */
31
+ const RETURN_ONLY_REFUND_OPERATION_TYPES = new Set(['CHANGE_RETURN']);
32
+
30
33
  export function releaseRefundDispositionOptions(
31
34
  allowed: readonly AdminAmendmentRefundDisposition[] | undefined,
32
35
  ): AdminRefundDispositionOption[] {
@@ -34,6 +37,59 @@ export function releaseRefundDispositionOptions(
34
37
  return RELEASE_OPTIONS.filter((option) => allowed.includes(option.value));
35
38
  }
36
39
 
40
+ /**
41
+ * True when every refund-decision operation is a return substitution.
42
+ * Combined with an unchanged product/outbound selection, this is the common
43
+ * "cheaper/later return" admin case that should default to no refund.
44
+ */
45
+ export function isReturnOnlyRefundDecision(
46
+ operations: readonly AdminAmendmentOperationSnapshot[],
47
+ ): boolean {
48
+ return (
49
+ operations.length > 0 &&
50
+ operations.every((operation) => RETURN_ONLY_REFUND_OPERATION_TYPES.has(operation.type))
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Only auto-default the low-stakes return-only case (same product/outbound → NO_REFUND).
56
+ * Other removals stay undecided so the operator must choose explicitly.
57
+ */
58
+ export function preferredAdminRefundDisposition(args: {
59
+ operations: readonly AdminAmendmentOperationSnapshot[];
60
+ /** Same parent product, option, date/time, passengers, and add-ons — only return differs. */
61
+ sameProductAndOutboundSelection: boolean;
62
+ allowed?: readonly AdminAmendmentRefundDisposition[];
63
+ }): AdminReleaseRefundDisposition | null {
64
+ if (
65
+ !args.sameProductAndOutboundSelection ||
66
+ !isReturnOnlyRefundDecision(args.operations)
67
+ ) {
68
+ return null;
69
+ }
70
+ const options = releaseRefundDispositionOptions(args.allowed);
71
+ return options.some((option) => option.value === 'NO_REFUND') ? 'NO_REFUND' : null;
72
+ }
73
+
74
+ export function buildDefaultRefundDispositionsByOperationId(args: {
75
+ operations: readonly AdminAmendmentOperationSnapshot[];
76
+ allowedByOperationId?: Readonly<
77
+ Record<string, readonly AdminAmendmentRefundDisposition[] | undefined>
78
+ >;
79
+ sameProductAndOutboundSelection: boolean;
80
+ }): Record<string, AdminReleaseRefundDisposition> {
81
+ return Object.fromEntries(
82
+ args.operations.flatMap((operation) => {
83
+ const disposition = preferredAdminRefundDisposition({
84
+ operations: args.operations,
85
+ sameProductAndOutboundSelection: args.sameProductAndOutboundSelection,
86
+ allowed: args.allowedByOperationId?.[operation.operationId],
87
+ });
88
+ return disposition ? [[operation.operationId, disposition] as const] : [];
89
+ }),
90
+ );
91
+ }
92
+
37
93
  export function adminRemovalOperationLabel(operation: AdminAmendmentOperationSnapshot): string {
38
94
  const component = operation.componentKey.split(':').slice(1).join(':') || operation.componentKey;
39
95
  const quantity = operation.quantityDelta ? ` (${Math.abs(operation.quantityDelta)})` : '';
@@ -153,7 +153,9 @@ export function useAdminChangeQuotePreview({
153
153
  !!initialValues?.bookingReference?.trim() &&
154
154
  !!lastName.trim();
155
155
 
156
- const isChangeQuoteBlocked = isCustomerSelfServeChange && latestChangeQuote?.canProceed === false;
156
+ const isChangeQuoteBlocked =
157
+ latestChangeQuote?.canProceed === false &&
158
+ (latestChangeQuote.refundDecisionOperations?.length ?? 0) === 0;
157
159
  const refundDecisionRequired = (latestChangeQuote?.refundDecisionOperations?.length ?? 0) > 0;
158
160
  const changeQuoteInputsKey = useMemo(() => buildAdminChangeQuoteRequestKey({
159
161
  bookingReference: initialValues?.bookingReference,
@@ -192,11 +194,19 @@ export function useAdminChangeQuotePreview({
192
194
  const changeCheckoutButtonLabel = (() => {
193
195
  if (!hasEffectiveChangeSelection) return undefined;
194
196
  if (isProviderDashboardChange) {
195
- const est = Math.round(changeFlowClientEstimateDue * 100) / 100;
196
- return est > 0
197
- ? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
198
- : est < 0
199
- ? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
197
+ if (isChangeQuoteBlocked) {
198
+ const tr = t('booking.changeBookingNotAvailable');
199
+ return tr !== 'booking.changeBookingNotAvailable' ? tr : 'Change not available';
200
+ }
201
+ // Prefer authoritative V2 signed due (charge positive / refund negative). Do not use the
202
+ // FE cart estimate once a server quote exists — receipt total deltas can disagree with
203
+ // floored/settlement amounts (DEFECT-PV2-018 follow-up).
204
+ const serverDue = latestChangeQuote?.serverPreview?.amountDue;
205
+ const due = Math.round((serverDue ?? changeFlowClientEstimateDue) * 100) / 100;
206
+ return due > 0
207
+ ? `Change booking (${formatCurrencyAmount(due, currency, locale as 'en' | 'fr')})`
208
+ : due < 0
209
+ ? `Change booking (${formatCurrencyAmount(due, currency, locale as 'en' | 'fr')})`
200
210
  : 'Change booking (no charge)';
201
211
  }
202
212
  if (changeFlowNeedsServerPrice) {
@@ -234,10 +244,10 @@ export function useAdminChangeQuotePreview({
234
244
  const checkoutFormError =
235
245
  (error || '') ||
236
246
  (missingRequiredReturnSelection ? 'Please select a return time for this product.' : '') ||
237
- (isCustomerSelfServeChange && isChangeQuoteBlocked && !refundDecisionRequired
238
- ? (latestChangeQuote?.reasonIfBlocked ?? '')
247
+ (isChangeQuoteBlocked && !refundDecisionRequired
248
+ ? (latestChangeQuote?.reasonIfBlocked ?? 'This change cannot be priced right now.')
239
249
  : '') ||
240
- (isCustomerSelfServeChange ? changeQuoteFetchError ?? '' : '');
250
+ (changeQuoteFetchError ?? '');
241
251
 
242
252
  const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
243
253
  if (!selectedAvailability || totalQuantity <= 0) return null;
@@ -251,12 +251,14 @@ export function mapQuoteLineItemsToPriceSummaryLines(
251
251
 
252
252
  function signedAmountDueFromQuote(quote: ChangeBookingQuoteResponse): number {
253
253
  const pricingQuote = quote.pricingQuote ?? quote.quote;
254
- if (quote.balanceDeltaMajorUnits != null) return quote.balanceDeltaMajorUnits;
255
- if (quote.balanceDelta != null) return quote.balanceDelta;
256
- if (pricingQuote?.balanceDelta != null) return pricingQuote.balanceDelta;
254
+ // Prefer explicit settlement fields over balanceDelta. Receipt-total math and balanceDelta
255
+ // can disagree with charge/refund when value is removed but not yet dispositioned.
257
256
  const charge = pricingQuote?.amountToCharge ?? quote.amountToCharge;
258
257
  const refund = pricingQuote?.refundCandidate ?? quote.refundCandidate;
259
258
  if (charge != null || refund != null) return (charge ?? 0) - (refund ?? 0);
259
+ if (quote.balanceDeltaMajorUnits != null) return quote.balanceDeltaMajorUnits;
260
+ if (quote.balanceDelta != null) return quote.balanceDelta;
261
+ if (pricingQuote?.balanceDelta != null) return pricingQuote.balanceDelta;
260
262
  if (quote.amountDueCents != null) return quote.amountDueCents / 100;
261
263
  return quote.priceDiff ?? 0;
262
264
  }
@@ -271,12 +271,14 @@ export function sliceChangeQuoteForUi(
271
271
  const pricingQuote = quote.pricingQuote ?? quote.quote;
272
272
  /** Staff JWT on quote: signed new − previous (refund owed negative). Otherwise same as legacy nonnegative amount-due. */
273
273
  const priceDiff = (() => {
274
+ const charge = quote.amountToCharge ?? pricingQuote?.amountToCharge;
275
+ const refund = quote.refundCandidate ?? pricingQuote?.refundCandidate;
276
+ if (charge != null || refund != null) {
277
+ return (charge ?? 0) - (refund ?? 0);
278
+ }
274
279
  if (quote.balanceDeltaMajorUnits != null) return quote.balanceDeltaMajorUnits;
275
280
  if (quote.balanceDelta != null) return quote.balanceDelta;
276
281
  if (pricingQuote?.balanceDelta != null) return pricingQuote.balanceDelta;
277
- if (quote.amountToCharge != null || quote.refundCandidate != null) {
278
- return (quote.amountToCharge ?? 0) - (quote.refundCandidate ?? 0);
279
- }
280
282
  if (quote.amountDueCents != null) return quote.amountDueCents / 100;
281
283
  return quote.priceDiff ?? 0;
282
284
  })();
@@ -1291,7 +1291,11 @@ export interface ChangeBookingQuoteRequest {
1291
1291
  previousAvailabilityId?: string | null;
1292
1292
  previousReturnAvailabilityId?: string | null;
1293
1293
  } | null;
1294
- /** Explicit admin treatment for server-classified removed paid value. Never defaulted by the client. */
1294
+ /**
1295
+ * Explicit admin treatment for server-classified removed paid value.
1296
+ * The dashboard may pre-select NO_REFUND for return-only changes (still overridable);
1297
+ * other removals stay undecided until chosen. The value is always sent explicitly on quote/apply.
1298
+ */
1295
1299
  refundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition>;
1296
1300
  }
1297
1301
 
@@ -15,7 +15,10 @@ import {
15
15
  } from '../src/lib/booking-api';
16
16
  import { buildAdminChangeQuoteRequestKey } from '../src/components/booking/admin-change-quote-request-key';
17
17
  import {
18
+ buildDefaultRefundDispositionsByOperationId,
19
+ isReturnOnlyRefundDecision,
18
20
  noRefundRemovedValue,
21
+ preferredAdminRefundDisposition,
19
22
  refundDecisionsComplete,
20
23
  releaseRefundDispositionOptions,
21
24
  } from '../src/components/booking/admin-refund-disposition';
@@ -186,6 +189,64 @@ test('admin removal quote preserves explicit release refund decisions for the pr
186
189
  );
187
190
  });
188
191
 
192
+ test('admin refund disposition defaults only for return-only → no refund', () => {
193
+ const changeReturn = {
194
+ operationId: 'op_change_return',
195
+ type: 'CHANGE_RETURN',
196
+ componentKey: 'return',
197
+ };
198
+ const removeAdult = {
199
+ operationId: 'op_remove_adult',
200
+ type: 'REMOVE_QUANTITY',
201
+ componentKey: 'ticket:ADULT',
202
+ quantityDelta: -1,
203
+ };
204
+
205
+ assert.equal(isReturnOnlyRefundDecision([changeReturn]), true);
206
+ assert.equal(isReturnOnlyRefundDecision([changeReturn, removeAdult]), false);
207
+ assert.equal(
208
+ preferredAdminRefundDisposition({
209
+ operations: [changeReturn],
210
+ sameProductAndOutboundSelection: true,
211
+ }),
212
+ 'NO_REFUND',
213
+ );
214
+ assert.equal(
215
+ preferredAdminRefundDisposition({
216
+ operations: [changeReturn],
217
+ sameProductAndOutboundSelection: false,
218
+ }),
219
+ null,
220
+ );
221
+ assert.equal(
222
+ preferredAdminRefundDisposition({
223
+ operations: [removeAdult],
224
+ sameProductAndOutboundSelection: true,
225
+ }),
226
+ null,
227
+ );
228
+ assert.deepEqual(
229
+ buildDefaultRefundDispositionsByOperationId({
230
+ operations: [changeReturn],
231
+ sameProductAndOutboundSelection: true,
232
+ allowedByOperationId: {
233
+ [changeReturn.operationId]: ['PENDING_REFUND', 'NO_REFUND'],
234
+ },
235
+ }),
236
+ { [changeReturn.operationId]: 'NO_REFUND' },
237
+ );
238
+ assert.deepEqual(
239
+ buildDefaultRefundDispositionsByOperationId({
240
+ operations: [removeAdult],
241
+ sameProductAndOutboundSelection: false,
242
+ allowedByOperationId: {
243
+ [removeAdult.operationId]: ['PENDING_REFUND', 'NO_REFUND'],
244
+ },
245
+ }),
246
+ {},
247
+ );
248
+ });
249
+
189
250
  test('changing admin refund treatment creates a distinct authoritative quote request', () => {
190
251
  const base = {
191
252
  bookingReference: 'DE8H04DX',