@ticketboothapp/booking 1.2.154 → 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.154",
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];
@@ -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)})` : '';
@@ -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',