@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 +1 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +42 -2
- package/src/components/booking/AdminChangeReceiptComparison.tsx +1 -1
- package/src/components/booking/admin-refund-disposition.ts +56 -0
- package/src/components/booking/useAdminChangeQuotePreview.ts +19 -9
- package/src/lib/booking/change-booking-server-preview.ts +5 -3
- package/src/lib/booking/change-flow-pricing.ts +5 -3
- 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];
|
|
@@ -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:
|
|
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 =
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
(
|
|
238
|
-
? (latestChangeQuote?.reasonIfBlocked ?? '')
|
|
247
|
+
(isChangeQuoteBlocked && !refundDecisionRequired
|
|
248
|
+
? (latestChangeQuote?.reasonIfBlocked ?? 'This change cannot be priced right now.')
|
|
239
249
|
: '') ||
|
|
240
|
-
(
|
|
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
|
-
|
|
255
|
-
|
|
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
|
})();
|
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',
|