@code-collective/booking-widget 1.0.15 → 1.0.17
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/CHANGELOG.md +38 -0
- package/README.md +20 -12
- package/dist/booking-widget.js +179 -185
- package/dist/booking-widget.min.js +6 -6
- package/dist/booking-widget.umd.cjs +2 -2
- package/package.json +1 -1
- package/src/lib/CheckoutModal.svelte +4 -1
- package/src/lib/CheckoutPanel.svelte +22 -1
- package/src/lib/PaymentPage.svelte +13 -3
- package/src/lib/api.ts +1 -28
- package/src/lib/checkout-payment-flow.svelte.ts +101 -105
- package/src/lib/generated-types.ts +0 -82
|
@@ -62,10 +62,20 @@
|
|
|
62
62
|
onCompleted: (e: any) => { cleanup(); onPaymentComplete(resultOf('completed', e)); },
|
|
63
63
|
onCancelled: (e: any) => { cleanup(); onPaymentComplete(resultOf('cancelled', e)); },
|
|
64
64
|
onExpired: (e: any) => { cleanup(); onPaymentComplete(resultOf('expired', e)); },
|
|
65
|
+
// Deliberately left alone: WIDGET-ARCHITECTURE.md documents that while Peach still has attempts
|
|
66
|
+
// left, it re-prompts inside this same iframe and shows its own decline UI ("4 of 5 card attempts
|
|
67
|
+
// remaining") without ending the checkout - and observed behaviour is that Peach fires onError for
|
|
68
|
+
// that case too, not only for a genuinely broken render. Unmounting and handing off to our own
|
|
69
|
+
// "Payment Failed" screen here would tear that UI down mid-retry, for a checkout Peach has not
|
|
70
|
+
// actually given up on - so this does not cleanup() or call onPaymentComplete at all. Peach's own
|
|
71
|
+
// iframe is left to do whatever it was going to do next; the only way out of a truly dead render is
|
|
72
|
+
// still the `failed` state above (SDK never loaded) and its own Start Over button.
|
|
65
73
|
onError: (e: any) => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
74
|
+
console.error(
|
|
75
|
+
`[booking-widget] Peach reported onError for checkoutId ${checkoutId} - left untouched, ` +
|
|
76
|
+
'Peach owns retries. Payload:',
|
|
77
|
+
e,
|
|
78
|
+
);
|
|
69
79
|
},
|
|
70
80
|
},
|
|
71
81
|
});
|
package/src/lib/api.ts
CHANGED
|
@@ -117,25 +117,16 @@ export interface BookingApi {
|
|
|
117
117
|
// after paying, so the widget only ever reads the outcome, it never produces one.
|
|
118
118
|
getCartStatus(): Promise<CheckoutCartStatusDto>;
|
|
119
119
|
|
|
120
|
-
// Asks the server to find out what actually happened to the current attempt. Called once when Peach's form
|
|
121
|
-
// reports the checkout complete - "complete" covers a declined card as much as a charged one, and Peach
|
|
122
|
-
// sends no webhook for a decline - and then getCartStatus is polled for the answer. Best effort: the server
|
|
123
|
-
// answers 204 whatever it found, and the sweep reconciles anything it could not.
|
|
124
|
-
resolvePayment(): Promise<void>;
|
|
125
120
|
// Slides the cart's idle window out to a full window from now, clamped at its absolute ceiling, and
|
|
126
121
|
// re-extends the OCTO holds behind it. There is no refusal to handle: a cart already at its ceiling comes
|
|
127
122
|
// back with the deadline it already had (see isAtExpiryCeiling). Returns the cart with its new expiry so the
|
|
128
123
|
// countdown resumes from the server's numbers rather than a local guess.
|
|
129
124
|
extendCart(): Promise<CheckoutCartDetailDto>;
|
|
130
|
-
// Reports a specific Peach
|
|
125
|
+
// Reports a specific Peach checkout dead so the server reopens the cart for a new one -
|
|
131
126
|
// used by CheckoutModal itself, which knows the checkoutId Peach's own callback fired for. See
|
|
132
127
|
// isPaymentSettled for the one refusal that changes what the caller does next.
|
|
133
128
|
abandonPayment(checkoutId: string): Promise<void>;
|
|
134
129
|
|
|
135
|
-
// The SDK-failure counterpart to abandonPayment, carrying whatever reason Peach gave. Same guard and same
|
|
136
|
-
// refusals server-side - the difference is that the attempt is recorded as failed with its code rather than
|
|
137
|
-
// simply abandoned, which for a card declined inside the checkout is the only place that code ever exists.
|
|
138
|
-
reportPaymentFailure(checkoutId: string, resultCode?: string, description?: string): Promise<void>;
|
|
139
130
|
}
|
|
140
131
|
|
|
141
132
|
export class ApiClient implements BookingApi {
|
|
@@ -294,30 +285,12 @@ export class ApiClient implements BookingApi {
|
|
|
294
285
|
return this.unwrap(await this.client.GET('/v1/checkout/cart/status'));
|
|
295
286
|
}
|
|
296
287
|
|
|
297
|
-
async resolvePayment(): Promise<void> {
|
|
298
|
-
const result = await this.client.POST('/v1/checkout/cart/resolve-payment');
|
|
299
|
-
if (result.response.status >= 400) {
|
|
300
|
-
throw new ApiError(result.response.status, result.error);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
288
|
async extendCart(): Promise<CheckoutCartDetailDto> {
|
|
305
289
|
return this.unwrap(
|
|
306
290
|
await this.client.POST('/v1/checkout/cart/extend'),
|
|
307
291
|
);
|
|
308
292
|
}
|
|
309
293
|
|
|
310
|
-
async reportPaymentFailure(checkoutId: string, resultCode?: string, description?: string): Promise<void> {
|
|
311
|
-
const result = await this.client.POST('/v1/checkout/cart/payment-failure', {
|
|
312
|
-
body: { checkoutId, resultCode, description },
|
|
313
|
-
});
|
|
314
|
-
// Same shape as abandonPayment below, including keeping the error body: the 409s carry a code the caller
|
|
315
|
-
// branches on (see isPaymentSettled).
|
|
316
|
-
if (result.response.status >= 400) {
|
|
317
|
-
throw new ApiError(result.response.status, result.error);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
294
|
async abandonPayment(checkoutId: string): Promise<void> {
|
|
322
295
|
const result = await this.client.POST('/v1/checkout/cart/abandon-payment', {
|
|
323
296
|
body: { checkoutId },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The state of one shopper's current Peach payment attempt, from minting it through to a settled outcome -
|
|
2
2
|
// split out of CheckoutModal.svelte because this was the single largest, most tangled concern in that file
|
|
3
3
|
// (roughly a third of it): initiating a payment, resuming one after a reload, releasing/reporting a dead or
|
|
4
|
-
//
|
|
4
|
+
// spent checkout, and polling the cart's status until it settles. None of it needs the cart's own item list,
|
|
5
5
|
// the contact form, or the wizard's cart/edit views - only the current payment attempt and where it stands.
|
|
6
6
|
|
|
7
7
|
import type { BookingApi } from './api';
|
|
@@ -18,6 +18,22 @@ import { cartDeadline } from './cart-expiry';
|
|
|
18
18
|
import { postMessage } from './messages';
|
|
19
19
|
import { forgetPaymentAttempt, recallPaymentAttempt, rememberPaymentAttempt } from './payment-attempt';
|
|
20
20
|
|
|
21
|
+
// Peach reports a finished checkout through onCompleted whether the transaction was charged or rejected, and
|
|
22
|
+
// the only place that distinction exists client-side is the result code in its payload. Mirrors the server's
|
|
23
|
+
// own PeachPaymentOutcome.Classify (Connect.Logic/Checkout/Payments) - the two must agree, so if Peach ever
|
|
24
|
+
// publishes a code family that one treats differently, change both.
|
|
25
|
+
//
|
|
26
|
+
// Deliberately conservative: a completed result whose code is missing or unrecognised is treated as a possible
|
|
27
|
+
// charge and goes to the poll, because showing "Payment Failed" over a card that was actually charged is the
|
|
28
|
+
// one outcome worth avoiding entirely. Only a code this recognises as a genuine failure takes the fail path.
|
|
29
|
+
const PeachSuccessCode = /^(000\.000\.|000\.100\.1|000\.[36]|000\.400\.1[12]0|000\.400\.0[^3]|000\.400\.100)/;
|
|
30
|
+
const PeachPendingCode = /^(000\.200|800\.400\.5|100\.400\.500)/;
|
|
31
|
+
|
|
32
|
+
function isFailureCode(resultCode: string | undefined): boolean {
|
|
33
|
+
if (!resultCode) return false;
|
|
34
|
+
return !PeachSuccessCode.test(resultCode) && !PeachPendingCode.test(resultCode);
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
// Peach's webhook is typically near-instant, but is a genuinely separate async delivery from the card charge
|
|
22
38
|
// itself, and confirming the bookings behind it means a call per item to a supplier. 90s at 1.5s intervals
|
|
23
39
|
// covers both without making a shopper whose payment already succeeded wait an unreasonable time to see it.
|
|
@@ -89,19 +105,18 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
89
105
|
// A reload mid-payment: the server still holds this cart in AwaitingPaymentConfirmation for the Peach
|
|
90
106
|
// checkout recorded before the reload, and will refuse every edit and a second payCart until that attempt
|
|
91
107
|
// is settled or abandoned. Where to land depends on how far the shopper got before the reload, and only the
|
|
92
|
-
// server knows:
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
108
|
+
// server knows: the charge may have landed a moment earlier, so the cart is read once first and a settled
|
|
109
|
+
// outcome is shown exactly as it would have been without the reload. Anything short of that puts the shopper
|
|
110
|
+
// back on the same card form, whose Peach handlers then route as they do without a reload: a completed
|
|
111
|
+
// charge confirms, a cancel/expiry/error releases the checkout and reopens the cart. A card declined in
|
|
112
|
+
// between changes nothing here - it never ended Peach's checkout, so the form is still the right place.
|
|
97
113
|
// Same resume as payNow's own paymentResult short-cut.
|
|
98
114
|
async function resumeInterruptedPayment(): Promise<void> {
|
|
99
115
|
const interruptedAttempt = recallPaymentAttempt(deps.getApi().cartToken);
|
|
100
116
|
if (!interruptedAttempt) return;
|
|
101
117
|
paymentResult = interruptedAttempt;
|
|
102
|
-
// Posted before the answer is known, as payNow does: CartExpiryGuard must treat the
|
|
103
|
-
// whichever screen it ends up on
|
|
104
|
-
// same as it does without a reload.
|
|
118
|
+
// Posted before the answer is known, as payNow does: CartExpiryGuard must treat the payment as in flight
|
|
119
|
+
// whichever screen it ends up on.
|
|
105
120
|
postMessage({ type: 'payment:started' });
|
|
106
121
|
if (await resumeOntoRecordedOutcome()) return;
|
|
107
122
|
deps.setView('payment');
|
|
@@ -111,14 +126,13 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
111
126
|
if (cartDeadline(deps.getCart()).getTime() <= Date.now()) void onPaymentTimedOut();
|
|
112
127
|
}
|
|
113
128
|
|
|
114
|
-
// Where a reload lands. One status read decides it: a
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// may never have submitted.
|
|
129
|
+
// Where a reload lands. One status read decides it: a confirmed cart goes straight to the result screen; a
|
|
130
|
+
// confirmation in flight joins the ordinary poll to read its outcome; a cart the server no longer has closes
|
|
131
|
+
// the modal, as a 401 does everywhere else here. Anything still unresolved - a shopper who never submitted
|
|
132
|
+
// the card, one who reloaded mid-3-D Secure, or one still working through declines on Peach's own form,
|
|
133
|
+
// none of which can be told apart from here - goes back to the card form, since that is Peach's own form for
|
|
134
|
+
// this same checkoutId rather than a fresh charge. An unreachable server is treated the same way, rather
|
|
135
|
+
// than parking them on a screen telling them not to pay again for a card they may never have submitted.
|
|
122
136
|
async function resumeOntoRecordedOutcome(): Promise<boolean> {
|
|
123
137
|
isConfirming = true;
|
|
124
138
|
deps.setView('result');
|
|
@@ -141,9 +155,13 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
141
155
|
return true;
|
|
142
156
|
}
|
|
143
157
|
|
|
144
|
-
|
|
158
|
+
// Expired while the tab was away - the sweep found a cart nobody came back to. There is no card form to
|
|
159
|
+
// return to, so this owns where the shopper lands rather than falling through to one.
|
|
160
|
+
if (status.cartStatus === 'expired') {
|
|
145
161
|
confirmOutcome = 'notCharged';
|
|
146
|
-
|
|
162
|
+
forgetPaymentAttempt();
|
|
163
|
+
paymentResult = null;
|
|
164
|
+
postMessage({ type: 'payment:ended' });
|
|
147
165
|
isConfirming = false;
|
|
148
166
|
return true;
|
|
149
167
|
}
|
|
@@ -158,24 +176,20 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
158
176
|
return false;
|
|
159
177
|
}
|
|
160
178
|
|
|
161
|
-
// Tells the server this
|
|
162
|
-
// AwaitingPaymentConfirmation, no webhook ever comes for a checkout nobody
|
|
163
|
-
//
|
|
164
|
-
//
|
|
179
|
+
// Tells the server this checkout is over so a fresh payCart will be accepted - without it the cart stays
|
|
180
|
+
// AwaitingPaymentConfirmation, no webhook ever comes for a checkout nobody paid, and the only thing a retry
|
|
181
|
+
// could do is reopen a Peach session that is already dead. 'settled' means Peach has this checkout as paid,
|
|
182
|
+
// or still in flight (PAYMENT_PENDING) - either way the cart must not be reopened, and the confirm loop
|
|
165
183
|
// already handles "not paid yet" by landing on the pending screen, so both take the same path here; 'failed'
|
|
166
184
|
// means the server could not be reached or refused for another reason, in which case paymentResult is kept
|
|
167
|
-
// so payNow falls back to resuming the same still-open
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
// declined
|
|
171
|
-
|
|
172
|
-
|
|
185
|
+
// so payNow falls back to resuming the same still-open checkout.
|
|
186
|
+
// One endpoint for every way out: a shopper backing out, a clock running out, and Peach's own SDK reporting
|
|
187
|
+
// the checkout unusable are all the same thing to this gateway - a checkout nothing was charged on. A
|
|
188
|
+
// declined card is not among them: Peach keeps the shopper on its own form to try again, and none of that
|
|
189
|
+
// ever reaches here.
|
|
190
|
+
async function abandonPaymentAttempt(checkoutId: string): Promise<'abandoned' | 'settled' | 'failed'> {
|
|
173
191
|
try {
|
|
174
|
-
|
|
175
|
-
await deps.getApi().reportPaymentFailure(checkoutId, failure.resultCode, failure.description);
|
|
176
|
-
} else {
|
|
177
|
-
await deps.getApi().abandonPayment(checkoutId);
|
|
178
|
-
}
|
|
192
|
+
await deps.getApi().abandonPayment(checkoutId);
|
|
179
193
|
return 'abandoned';
|
|
180
194
|
} catch (e) {
|
|
181
195
|
return isPaymentSettled(e) || isPaymentPending(e) ? 'settled' : 'failed';
|
|
@@ -250,19 +264,13 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
250
264
|
}
|
|
251
265
|
|
|
252
266
|
async function onPaymentComplete(result: PaymentSdkResult): Promise<void> {
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
// so the attempt is abandoned server-side (a retry then starts a genuinely new Peach checkout instead of
|
|
261
|
-
// reopening this dead one) and re-opening the card form is exactly correct.
|
|
262
|
-
if (result.status === 'cancelled' || result.status === 'expired' || result.status === 'error') {
|
|
263
|
-
// Only the two genuine failures carry Peach's own reason; a shopper backing out has none to give.
|
|
264
|
-
const failure = result.status === 'cancelled' ? undefined : result;
|
|
265
|
-
if (!(await releaseAbandonedAttempt(failure))) return;
|
|
267
|
+
// Cancelled and expired are genuinely terminal on Peach's side: the shopper backed out, or the session
|
|
268
|
+
// timed out. error never reaches here from Peach's own iframe any more (PaymentPage's onError is left
|
|
269
|
+
// untouched - Peach owns retries and shows its own "4 of 5 attempts remaining" UI without ending the
|
|
270
|
+
// checkout) - the only source left is the `failed` state's Start Over button, for a checkout that never
|
|
271
|
+
// even rendered. That is handled below, mirrored on the isFailureCode branch, rather than released here.
|
|
272
|
+
if (result.status === 'cancelled' || result.status === 'expired') {
|
|
273
|
+
if (!(await releaseAbandonedAttempt())) return;
|
|
266
274
|
}
|
|
267
275
|
|
|
268
276
|
if (result.status === 'cancelled') {
|
|
@@ -270,34 +278,53 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
270
278
|
return;
|
|
271
279
|
}
|
|
272
280
|
|
|
273
|
-
if (result.status === 'expired'
|
|
281
|
+
if (result.status === 'expired') {
|
|
274
282
|
confirmOutcome = 'notCharged';
|
|
275
283
|
isConfirming = false;
|
|
276
284
|
deps.setView('result');
|
|
277
285
|
return;
|
|
278
286
|
}
|
|
279
287
|
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
288
|
+
// A completed checkout is not the same as a charged one. Peach fires onCompleted for a transaction it
|
|
289
|
+
// rejected as well as one it charged, and by the time it does so its own form has already offered the
|
|
290
|
+
// shopper every retry it was going to - so this is the end of the road, not a state to wait on. The
|
|
291
|
+
// Start-Over error case is folded in here rather than treated as its own terminal branch above: a
|
|
292
|
+
// checkout that never rendered gets the same answer as one Peach rejected - nothing was charged, and the
|
|
293
|
+
// cart is left exactly as it is either way.
|
|
294
|
+
//
|
|
295
|
+
// Nothing is asked of the server here, deliberately. Peach's checkout-level status endpoint answers
|
|
296
|
+
// "transaction pending" for a checkout whose transaction was rejected, so consulting it would turn a known
|
|
297
|
+
// failure back into a guess; it is only good for confirming a success. The cart is left exactly as it is -
|
|
298
|
+
// the next Pay Now mints a new checkout over this one (CheckoutCartService.CanInitiatePayment: a cart in
|
|
299
|
+
// AwaitingPaymentConfirmation stays payable specifically because "a decline is Peach's business"). Calling
|
|
300
|
+
// abandon-payment here instead - as this used to - only ever pre-empted that: it would either abort an
|
|
301
|
+
// attempt Peach had not actually given up on, or be flatly refused with a 409 because Peach's own status
|
|
302
|
+
// already disagreed, sending this down the exact same path anyway.
|
|
303
|
+
if (result.status === 'error' || isFailureCode(result.resultCode)) {
|
|
304
|
+
paymentResult = null;
|
|
305
|
+
forgetPaymentAttempt();
|
|
306
|
+
confirmOutcome = 'notCharged';
|
|
307
|
+
isConfirming = false;
|
|
308
|
+
deps.setView('result');
|
|
309
|
+
// CartExpiryGuard has to stop treating a payment as in flight, or it never prompts or expires again.
|
|
310
|
+
postMessage({ type: 'payment:ended' });
|
|
311
|
+
return;
|
|
289
312
|
}
|
|
313
|
+
|
|
314
|
+
// A charge, or a code this cannot read. The webhook is what moves the cart to Paid; this poll only reads
|
|
315
|
+
// the cart until it says so.
|
|
316
|
+
deps.setView('result');
|
|
290
317
|
await runStatusPoll();
|
|
291
318
|
}
|
|
292
319
|
|
|
293
320
|
// False when this flow has already taken over what happens next - the cart turned out to be paid and is
|
|
294
321
|
// being confirmed, or it has expired - so the caller must not route the shopper anywhere else.
|
|
295
|
-
async function releaseAbandonedAttempt(
|
|
322
|
+
async function releaseAbandonedAttempt(): Promise<boolean> {
|
|
296
323
|
// Already cleared by an earlier call for this same attempt (e.g. a second Peach callback after the first
|
|
297
324
|
// already abandoned it) - nothing left to tell the server about.
|
|
298
325
|
if (!paymentResult) return await deps.reloadCart();
|
|
299
326
|
|
|
300
|
-
const abandoned = await abandonPaymentAttempt(paymentResult.checkoutId
|
|
327
|
+
const abandoned = await abandonPaymentAttempt(paymentResult.checkoutId);
|
|
301
328
|
if (abandoned === 'settled') {
|
|
302
329
|
deps.setView('result');
|
|
303
330
|
await runStatusPoll();
|
|
@@ -323,49 +350,21 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
323
350
|
}
|
|
324
351
|
|
|
325
352
|
// Back to the contact form rather than straight to 'payment'. Every outcome that offers Try Again is one
|
|
326
|
-
// where nothing was charged
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
// short-cut resumes that same one from here, which is exactly what it is there for.
|
|
353
|
+
// where nothing was charged and the checkout behind it has already been released (a Peach-reported expiry or
|
|
354
|
+
// error in onPaymentComplete) - so there is no card form left to return to: Peach's SDK will not re-render a
|
|
355
|
+
// checkoutId it has unmounted. Contact is where payNow creates a genuinely new checkout, with the shopper's
|
|
356
|
+
// details still filled in (they live in the parent component, not in ContactForm) so paying again is one
|
|
357
|
+
// click away. The one exception needs no handling of its own: after an abandon the server refused, the
|
|
358
|
+
// checkout is still open and payNow's own paymentResult short-cut resumes that same one from here, which is
|
|
359
|
+
// exactly what it is there for.
|
|
334
360
|
function retry(): void {
|
|
335
361
|
deps.setView('contact');
|
|
336
362
|
}
|
|
337
363
|
|
|
338
|
-
// The declined counterpart to releaseAbandonedAttempt, and deliberately without its abandon call: the server
|
|
339
|
-
// has already resolved this attempt itself - a decline reached it (Peach's webhook, or its own read of Peach's
|
|
340
|
-
// status endpoint on the status poll, since Peach sends no webhook for a declined card), which is what moved
|
|
341
|
-
// the cart to Failed - so there is nothing left to report. AbandonPaymentAsync answers a Failed cart with
|
|
342
|
-
// AlreadyOpen, an explicit no-op.
|
|
343
|
-
// Clearing the attempt is what makes Try Again start a NEW Peach checkout instead of reopening the dead one:
|
|
344
|
-
// payNow resumes paymentResult when it is still set, and resumeInterruptedPayment would land a reload
|
|
345
|
-
// straight back on that same spent card form.
|
|
346
|
-
async function releaseDeclinedAttempt(): Promise<void> {
|
|
347
|
-
paymentResult = null;
|
|
348
|
-
forgetPaymentAttempt();
|
|
349
|
-
// Failed is live and still payable again - the decline leaves the cart's one clock exactly where it was -
|
|
350
|
-
// so this re-reads whatever time Try Again actually has left rather than the deadline from before the card
|
|
351
|
-
// form. A cart whose clock ran out meanwhile 401s here and closes the modal, same as anywhere else. Any
|
|
352
|
-
// other failure of the re-read must not escape: this runs inside resumeOntoRecordedOutcome's own catch,
|
|
353
|
-
// from where a throw would strand the shopper on "Verifying payment..." with no button at all, and the
|
|
354
|
-
// decline itself is already recorded - Try Again simply counts down from the older deadline (PR 8447 review).
|
|
355
|
-
try {
|
|
356
|
-
if (await deps.reloadCart()) deps.notifyCartUpdated(deps.getCart());
|
|
357
|
-
} catch {
|
|
358
|
-
// see above
|
|
359
|
-
}
|
|
360
|
-
// Without this CartExpiryGuard keeps treating a payment as in flight for the rest of the page's life, so it
|
|
361
|
-
// never prompts or expires this cart again.
|
|
362
|
-
postMessage({ type: 'payment:ended' });
|
|
363
|
-
}
|
|
364
|
-
|
|
365
364
|
// One status read, interpreted - the single place that decides what a given cart state means, shared by the
|
|
366
|
-
// automatic poll and the manual on-demand recheck. 'settled' has recorded the outcome (success, a
|
|
367
|
-
// supplier-side partial failure
|
|
368
|
-
//
|
|
365
|
+
// automatic poll and the manual on-demand recheck. 'settled' has recorded the outcome (success, or a
|
|
366
|
+
// supplier-side partial failure); 'waiting' means the backend has not finished and another read is worth
|
|
367
|
+
// making.
|
|
369
368
|
async function pollOnce(signal?: AbortSignal): Promise<'settled' | 'waiting'> {
|
|
370
369
|
let status;
|
|
371
370
|
try {
|
|
@@ -394,15 +393,12 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
394
393
|
return 'settled';
|
|
395
394
|
}
|
|
396
395
|
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
if (status.paymentOutcome === 'failed' || status.paymentOutcome === 'abandoned') {
|
|
396
|
+
// The stuck-payment sweep released this cart: nothing was charged, nothing is coming, and the holds
|
|
397
|
+
// behind it are being cancelled. Terminal - polling on would just wait out the window.
|
|
398
|
+
if (status.cartStatus === 'expired') {
|
|
401
399
|
confirmOutcome = 'notCharged';
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
// is nothing left to report to it.
|
|
405
|
-
await releaseDeclinedAttempt();
|
|
400
|
+
forgetPaymentAttempt();
|
|
401
|
+
paymentResult = null;
|
|
406
402
|
return 'settled';
|
|
407
403
|
}
|
|
408
404
|
|
|
@@ -108,23 +108,6 @@ export interface paths {
|
|
|
108
108
|
patch?: never;
|
|
109
109
|
trace?: never;
|
|
110
110
|
};
|
|
111
|
-
"/v1/checkout/cart/resolve-payment": {
|
|
112
|
-
parameters: {
|
|
113
|
-
query?: never;
|
|
114
|
-
header?: never;
|
|
115
|
-
path?: never;
|
|
116
|
-
cookie?: never;
|
|
117
|
-
};
|
|
118
|
-
get?: never;
|
|
119
|
-
put?: never;
|
|
120
|
-
/** Resolve checkout cart payment */
|
|
121
|
-
post: operations["ResolveCheckoutCartPayment_v1"];
|
|
122
|
-
delete?: never;
|
|
123
|
-
options?: never;
|
|
124
|
-
head?: never;
|
|
125
|
-
patch?: never;
|
|
126
|
-
trace?: never;
|
|
127
|
-
};
|
|
128
111
|
"/v1/checkout/cart/status": {
|
|
129
112
|
parameters: {
|
|
130
113
|
query?: never;
|
|
@@ -159,23 +142,6 @@ export interface paths {
|
|
|
159
142
|
patch?: never;
|
|
160
143
|
trace?: never;
|
|
161
144
|
};
|
|
162
|
-
"/v1/checkout/cart/payment-failure": {
|
|
163
|
-
parameters: {
|
|
164
|
-
query?: never;
|
|
165
|
-
header?: never;
|
|
166
|
-
path?: never;
|
|
167
|
-
cookie?: never;
|
|
168
|
-
};
|
|
169
|
-
get?: never;
|
|
170
|
-
put?: never;
|
|
171
|
-
/** Report checkout cart payment failure */
|
|
172
|
-
post: operations["ReportCheckoutCartPaymentFailure_v1"];
|
|
173
|
-
delete?: never;
|
|
174
|
-
options?: never;
|
|
175
|
-
head?: never;
|
|
176
|
-
patch?: never;
|
|
177
|
-
trace?: never;
|
|
178
|
-
};
|
|
179
145
|
"/v1/checkout/cart/abandon-payment": {
|
|
180
146
|
parameters: {
|
|
181
147
|
query?: never;
|
|
@@ -454,11 +420,6 @@ export interface components {
|
|
|
454
420
|
CheckoutCartPayDto: {
|
|
455
421
|
contact?: components["schemas"]["OctoContact"];
|
|
456
422
|
};
|
|
457
|
-
CheckoutCartPaymentFailureDto: {
|
|
458
|
-
checkoutId?: string | null;
|
|
459
|
-
resultCode?: string | null;
|
|
460
|
-
description?: string | null;
|
|
461
|
-
};
|
|
462
423
|
CheckoutCartPaymentInitiationDto: {
|
|
463
424
|
checkoutId?: string | null;
|
|
464
425
|
redirectUrl?: string | null;
|
|
@@ -484,9 +445,6 @@ export interface components {
|
|
|
484
445
|
};
|
|
485
446
|
CheckoutCartStatusDto: {
|
|
486
447
|
cartStatus?: string | null;
|
|
487
|
-
paymentOutcome?: string | null;
|
|
488
|
-
resolvedBy?: string | null;
|
|
489
|
-
resultCode?: string | null;
|
|
490
448
|
confirmResult?: components["schemas"]["CheckoutCartConfirmResultDto"];
|
|
491
449
|
};
|
|
492
450
|
CheckoutFaqDto: {
|
|
@@ -814,24 +772,6 @@ export interface operations {
|
|
|
814
772
|
};
|
|
815
773
|
};
|
|
816
774
|
};
|
|
817
|
-
ResolveCheckoutCartPayment_v1: {
|
|
818
|
-
parameters: {
|
|
819
|
-
query?: never;
|
|
820
|
-
header?: never;
|
|
821
|
-
path?: never;
|
|
822
|
-
cookie?: never;
|
|
823
|
-
};
|
|
824
|
-
requestBody?: never;
|
|
825
|
-
responses: {
|
|
826
|
-
/** @description No Content */
|
|
827
|
-
204: {
|
|
828
|
-
headers: {
|
|
829
|
-
[name: string]: unknown;
|
|
830
|
-
};
|
|
831
|
-
content?: never;
|
|
832
|
-
};
|
|
833
|
-
};
|
|
834
|
-
};
|
|
835
775
|
GetCheckoutCartStatus_v1: {
|
|
836
776
|
parameters: {
|
|
837
777
|
query?: never;
|
|
@@ -872,28 +812,6 @@ export interface operations {
|
|
|
872
812
|
};
|
|
873
813
|
};
|
|
874
814
|
};
|
|
875
|
-
ReportCheckoutCartPaymentFailure_v1: {
|
|
876
|
-
parameters: {
|
|
877
|
-
query?: never;
|
|
878
|
-
header?: never;
|
|
879
|
-
path?: never;
|
|
880
|
-
cookie?: never;
|
|
881
|
-
};
|
|
882
|
-
requestBody: {
|
|
883
|
-
content: {
|
|
884
|
-
"application/json": components["schemas"]["CheckoutCartPaymentFailureDto"];
|
|
885
|
-
};
|
|
886
|
-
};
|
|
887
|
-
responses: {
|
|
888
|
-
/** @description No Content */
|
|
889
|
-
204: {
|
|
890
|
-
headers: {
|
|
891
|
-
[name: string]: unknown;
|
|
892
|
-
};
|
|
893
|
-
content?: never;
|
|
894
|
-
};
|
|
895
|
-
};
|
|
896
|
-
};
|
|
897
815
|
AbandonCheckoutCartPayment_v1: {
|
|
898
816
|
parameters: {
|
|
899
817
|
query?: never;
|