@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 +1 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +37 -1
- package/src/components/booking/AdminChangeReceiptComparison.tsx +1 -1
- package/src/components/booking/admin-refund-disposition.ts +56 -0
- package/src/lib/booking-api.ts +5 -1
- package/test/change-booking-helpers.test.ts +61 -0
package/package.json
CHANGED
|
@@ -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
|
|
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)})` : '';
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -1291,7 +1291,11 @@ export interface ChangeBookingQuoteRequest {
|
|
|
1291
1291
|
previousAvailabilityId?: string | null;
|
|
1292
1292
|
previousReturnAvailabilityId?: string | null;
|
|
1293
1293
|
} | null;
|
|
1294
|
-
/**
|
|
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',
|