@code-collective/booking-widget 1.0.13 → 1.0.14

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.
@@ -0,0 +1,523 @@
1
+ // The state of one shopper's current Peach payment attempt, from minting it through to a settled outcome -
2
+ // split out of CheckoutModal.svelte because this was the single largest, most tangled concern in that file
3
+ // (roughly a third of it): initiating a payment, resuming one after a reload, releasing/reporting a dead or
4
+ // declined attempt, and polling the cart's status until it settles. None of it needs the cart's own item list,
5
+ // the contact form, or the wizard's cart/edit views - only the current payment attempt and where it stands.
6
+
7
+ import type { BookingApi } from './api';
8
+ import { ApiError, isInvalidCart, isPaymentPending, isPaymentSettled } from './api';
9
+ import type {
10
+ CheckoutCartConfirmResultDto,
11
+ CheckoutCartDetailDto,
12
+ CheckoutCartPaymentInitiationDto,
13
+ OctoContact,
14
+ PaymentSdkResult,
15
+ PaymentStatus,
16
+ } from './client-types';
17
+ import { cartDeadline } from './cart-expiry';
18
+ import { postMessage } from './messages';
19
+ import { forgetPaymentAttempt, recallPaymentAttempt, rememberPaymentAttempt } from './payment-attempt';
20
+
21
+ // Peach's webhook is typically near-instant, but is a genuinely separate async delivery from the card charge
22
+ // itself, and confirming the bookings behind it means a call per item to a supplier. 90s at 1.5s intervals
23
+ // covers both without making a shopper whose payment already succeeded wait an unreasonable time to see it.
24
+ // Running out is not a failure - see runStatusPoll.
25
+ const StatusPollIntervalMs = 1500;
26
+ const StatusPollTimeoutMs = 90_000;
27
+
28
+ export interface CheckoutPaymentFlowDeps {
29
+ getApi: () => BookingApi;
30
+ getCart: () => CheckoutCartDetailDto;
31
+ // Re-reads the cart from the server and applies it; false (with the modal already closed) when the server
32
+ // no longer has it. Shared with cart-item editing, which needs the exact same behaviour - owned by the
33
+ // parent so this module does not have to reimplement "close on a gone cart" itself.
34
+ reloadCart: () => Promise<boolean>;
35
+ notifyCartUpdated: (cart: CheckoutCartDetailDto) => void;
36
+ // Paying does not move the cart's clock, but a still-there answer given between the parent's last read and
37
+ // the pay click could have, so payNow re-reads the deadline rather than assuming it. Best-effort by design
38
+ // (see CheckoutModal's own former comment) - a failed re-read just keeps the older deadline.
39
+ refreshDeadlineAfterPay: () => void;
40
+ setView: (view: 'contact' | 'payment' | 'result') => void;
41
+ close: () => void;
42
+ onOrderConfirmed: () => void;
43
+ }
44
+
45
+ export interface CheckoutPaymentFlow {
46
+ readonly paymentResult: CheckoutCartPaymentInitiationDto | null;
47
+ readonly isPaying: boolean;
48
+ readonly payError: string | null;
49
+ readonly isConfirming: boolean;
50
+ // A straight readout onto ResultView's own outcome union - see the derivation below for what each case
51
+ // means and why 'partial' has to stay distinct from both 'pending' and a plain failure.
52
+ readonly resultStatus: PaymentStatus | null;
53
+ payNow(contact: OctoContact): Promise<void>;
54
+ onPaymentComplete(result: PaymentSdkResult): Promise<void>;
55
+ onPaymentTimedOut(): Promise<void>;
56
+ resumeInterruptedPayment(): Promise<void>;
57
+ retry(): void;
58
+ checkStatusAgain(): Promise<void>;
59
+ /** Aborts any in-flight poll - call from onDestroy so a gone component cannot keep hitting the gateway. */
60
+ dispose(): void;
61
+ }
62
+
63
+ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): CheckoutPaymentFlow {
64
+ let paymentResult = $state<CheckoutCartPaymentInitiationDto | null>(null);
65
+ // What polling the cart's status actually established - not a plain success/failure boolean, because
66
+ // "payment succeeded but this gateway couldn't confirm it in time" and "payment itself never went through"
67
+ // need different copy and different actions (see resultStatus and runStatusPoll's own remarks). null while
68
+ // still in flight.
69
+ let confirmOutcome = $state<'success' | 'pending' | 'partial' | 'notCharged' | null>(null);
70
+ // Cancels an in-flight poll - on dispose, and when a retry supersedes it. Without this the old loop keeps
71
+ // running after the caller is gone and can write its own stale answer over the new attempt's.
72
+ let activePoll: AbortController | null = null;
73
+ let isConfirming = $state(false);
74
+ let isPaying = $state(false);
75
+ let payError = $state<string | null>(null);
76
+
77
+ // The cart's one clock ran out while Peach's card form was open (CartExpiryGuard cannot act on that itself -
78
+ // the server exempts a cart with a payment in flight from expiry so a landing charge can still be confirmed).
79
+ // The shopper should see the cart expire here like anywhere else, so the attempt is torn down through the
80
+ // same Peach-verified abandon a cancel uses: a charge Peach reports as landed or still in flight is never torn
81
+ // down (releaseAbandonedAttempt routes those to the confirm loop instead), and an attempt Peach confirms dead
82
+ // reopens the cart on its already-past deadline, so the reload that follows gets a 401 and the guard's
83
+ // payment:ended re-check shows "Your cart has expired". Nothing to do if no attempt is open right now.
84
+ async function onPaymentTimedOut(): Promise<void> {
85
+ if (!paymentResult) return;
86
+ await releaseAbandonedAttempt();
87
+ }
88
+
89
+ // A reload mid-payment: the server still holds this cart in AwaitingPaymentConfirmation for the Peach
90
+ // checkout recorded before the reload, and will refuse every edit and a second payCart until that attempt
91
+ // is settled or abandoned. Where to land depends on how far the shopper got before the reload, and only the
92
+ // server knows: Peach's form may have completed a moment earlier - a charge, or a decline the server reads
93
+ // straight back from Peach's status endpoint, since Peach sends no webhook for one - so it is asked once
94
+ // first, and a recorded outcome is shown exactly as it would have been without the reload. Only "not paid"
95
+ // puts the shopper back on that same card form, whose Peach handlers then route as they do without a
96
+ // reload: a completed charge confirms, a cancel/expiry/error abandons the attempt and reopens the cart.
97
+ // Same resume as payNow's own paymentResult short-cut.
98
+ async function resumeInterruptedPayment(): Promise<void> {
99
+ const interruptedAttempt = recallPaymentAttempt(deps.getApi().cartToken);
100
+ if (!interruptedAttempt) return;
101
+ paymentResult = interruptedAttempt;
102
+ // Posted before the answer is known, as payNow does: CartExpiryGuard must treat the attempt as in flight
103
+ // whichever screen it ends up on, and a decline's own payment:ended (releaseDeclinedAttempt) then reads the
104
+ // same as it does without a reload.
105
+ postMessage({ type: 'payment:started' });
106
+ if (await resumeOntoRecordedOutcome()) return;
107
+ deps.setView('payment');
108
+ // The guard's watcher fires once per deadline and may already have done so before this flow existed (a
109
+ // reload after the clock ran out, or checkout reopened late). Nothing else would tear the attempt down
110
+ // then; the shopper would land on a card form for a cart that is already gone.
111
+ if (cartDeadline(deps.getCart()).getTime() <= Date.now()) void onPaymentTimedOut();
112
+ }
113
+
114
+ // Where a reload lands. One status read decides it: a settled attempt (a charge, a supplier-side partial
115
+ // failure, or a decline) goes straight to the result screen; a confirmation in flight joins the ordinary
116
+ // poll to read its outcome; a cart the server no longer has closes the modal, as a 401 does everywhere else
117
+ // here. Anything still genuinely unresolved - a shopper who never submitted the card, or one who reloaded
118
+ // mid-3-D Secure, which cannot be told apart from here (PR 8447 review) - goes back to the card form, since
119
+ // that is Peach's own form for this same checkoutId rather than a fresh charge. An unreachable server is
120
+ // treated the same way, rather than parking them on a screen telling them not to pay again for a card they
121
+ // may never have submitted.
122
+ async function resumeOntoRecordedOutcome(): Promise<boolean> {
123
+ isConfirming = true;
124
+ deps.setView('result');
125
+
126
+ let status;
127
+ try {
128
+ status = await deps.getApi().getCartStatus();
129
+ } catch (e) {
130
+ isConfirming = false;
131
+ if (isInvalidCart(e)) {
132
+ deps.close();
133
+ return true;
134
+ }
135
+ return false;
136
+ }
137
+
138
+ if (status.cartStatus === 'confirmed') {
139
+ recordConfirmation(status.confirmResult ?? { items: [] });
140
+ isConfirming = false;
141
+ return true;
142
+ }
143
+
144
+ if (status.paymentOutcome === 'failed' || status.paymentOutcome === 'abandoned') {
145
+ confirmOutcome = 'notCharged';
146
+ await releaseDeclinedAttempt();
147
+ isConfirming = false;
148
+ return true;
149
+ }
150
+
151
+ // Paid or confirming: the money is in and the bookings are being turned into a sale right now.
152
+ if (status.cartStatus === 'paid' || status.cartStatus === 'confirming') {
153
+ await runStatusPoll();
154
+ return true;
155
+ }
156
+
157
+ isConfirming = false;
158
+ return false;
159
+ }
160
+
161
+ // Tells the server this attempt is over so a fresh payCart will be accepted - without it the cart stays
162
+ // AwaitingPaymentConfirmation, no webhook ever comes for a checkout nobody submitted, and the only thing a
163
+ // retry could do is reopen a Peach session that is already dead. 'settled' means Peach has this attempt as
164
+ // paid, or still in flight (PAYMENT_PENDING) - either way the cart must not be reopened, and the confirm loop
165
+ // already handles "not paid yet" by landing on the pending screen, so both take the same path here; 'failed'
166
+ // 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 attempt.
168
+ // Two endpoints, one guard behind them: a shopper backing out or a clock running out is an abandon, while
169
+ // Peach's own SDK reporting a failure carries a reason worth recording against the attempt - and for a card
170
+ // declined inside the checkout, where Peach sends no webhook, that payload is the only place its code exists.
171
+ async function abandonPaymentAttempt(checkoutId: string, failure?: PaymentSdkResult):
172
+ Promise<'abandoned' | 'settled' | 'failed'> {
173
+ try {
174
+ if (failure) {
175
+ await deps.getApi().reportPaymentFailure(checkoutId, failure.resultCode, failure.description);
176
+ } else {
177
+ await deps.getApi().abandonPayment(checkoutId);
178
+ }
179
+ return 'abandoned';
180
+ } catch (e) {
181
+ return isPaymentSettled(e) || isPaymentPending(e) ? 'settled' : 'failed';
182
+ }
183
+ }
184
+
185
+ async function payNow(contact: OctoContact): Promise<void> {
186
+ // paymentResult is only ever still set here when the previous attempt could not be torn down: the abandon
187
+ // call failed (Peach unreachable), or Peach reported the charge as settled or still in flight. Every other
188
+ // way out of PaymentPage - cancel, expiry, error - releases the attempt and clears paymentResult, and the
189
+ // next Pay Now creates a NEW Peach checkout. That is deliberate, not waste: Peach's SDK will not re-render
190
+ // a checkoutId it has already unmounted, a cancelled checkout is finished on Peach's side, and the total is
191
+ // frozen at creation so an edited cart needs a new one regardless.
192
+ //
193
+ // Which is exactly why this asks the server to release the attempt once more before anything else, rather
194
+ // than resuming it on the spot as it used to: that put the shopper back on a card form for a checkoutId
195
+ // Peach was done with, and Peach's own unrecoverable-error card in front of them instead of ours (PR 8447
196
+ // review). A release that succeeds leaves the cart Open and falls through to a genuinely new checkout. One
197
+ // that reports it has taken over - the charge turned out to have settled after all, or the cart is gone -
198
+ // owns what happens next, and paying again is the one thing that must not happen. Only a release that fails
199
+ // again leaves the server still holding the attempt (a second payCart would be refused with a 409), and
200
+ // then resuming that same card form really is the only move left.
201
+ if (paymentResult) {
202
+ isPaying = true;
203
+ try {
204
+ if (!(await releaseAbandonedAttempt())) return;
205
+ } catch {
206
+ // reloadCart rethrows anything that is not the cart being gone, and releaseAbandonedAttempt re-reads
207
+ // the cart on its way out. The attempt's own outcome is already decided by then, so the two branches
208
+ // below still route correctly on it; all a failed re-read costs is a countdown read a moment ago.
209
+ } finally {
210
+ isPaying = false;
211
+ }
212
+
213
+ if (paymentResult) {
214
+ deps.setView('payment');
215
+ return;
216
+ }
217
+ }
218
+
219
+ isPaying = true;
220
+ payError = null;
221
+ try {
222
+ paymentResult = await deps.getApi().payCart(contact);
223
+ rememberPaymentAttempt(deps.getApi().cartToken, {
224
+ checkoutId: paymentResult.checkoutId ?? '',
225
+ entityId: paymentResult.entityId ?? '',
226
+ });
227
+ deps.setView('payment');
228
+ postMessage({ type: 'payment:started' });
229
+ deps.refreshDeadlineAfterPay();
230
+ } catch (e) {
231
+ if (isInvalidCart(e)) {
232
+ // The cart expired between CartExpiryGuard's last tick and this click - there is nothing left to pay for.
233
+ deps.close();
234
+ return;
235
+ }
236
+
237
+ // PayCheckoutCartCommandHandler's own error-mapping switch: 502 means Peach itself (or the network
238
+ // path to it) failed before a checkout could even be created - no charge was attempted and the cart is
239
+ // still Open, so this is always safe to retry. Every other status (400/409) reflects a cart/contact state
240
+ // the shopper can't fix by only retrying the same request, but there is no more specific action to suggest
241
+ // here either - either way, without this catch the rejection was previously unhandled and the shopper
242
+ // saw no indication anything had gone wrong at all.
243
+ const status = e instanceof ApiError ? e.status : null;
244
+ payError = status === 502
245
+ ? 'Unable to start payment right now. Please try again.'
246
+ : 'Something went wrong starting your payment. Please try again.';
247
+ } finally {
248
+ isPaying = false;
249
+ }
250
+ }
251
+
252
+ async function onPaymentComplete(result: PaymentSdkResult): Promise<void> {
253
+ // Neither cancelled, expired nor error ever reached a real charge - Peach's own SDK is reporting that the
254
+ // checkout itself didn't go through (the shopper backed out, session timeout, a declined card, a 3DS
255
+ // failure, a client-side error), not that a successful charge's confirmation is in question. That
256
+ // distinction matters: the confirm path at the bottom is only reachable once onCompleted has fired, which
257
+ // Peach does for a settled outcome of either kind - a charge, or a decline (the server reads that back from
258
+ // Peach's status endpoint, since Peach sends no webhook for one, and answers PAYMENT_FAILED). So "try again"
259
+ // there is only ever offered once the server itself has said nothing was charged. Here nothing was charged,
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;
266
+ }
267
+
268
+ if (result.status === 'cancelled') {
269
+ deps.setView('contact');
270
+ return;
271
+ }
272
+
273
+ if (result.status === 'expired' || result.status === 'error') {
274
+ confirmOutcome = 'notCharged';
275
+ isConfirming = false;
276
+ deps.setView('result');
277
+ return;
278
+ }
279
+
280
+ // Peach's form has reported the checkout complete, which covers a declined card as much as a charged one.
281
+ // The server is asked once to go and find out which, because no webhook is coming for a decline; the poll
282
+ // below then reads whatever it established. Best effort - a failure here only means the answer arrives
283
+ // from the webhook or the sweep instead, which is exactly what the poll is already waiting for.
284
+ deps.setView('result');
285
+ try {
286
+ await deps.getApi().resolvePayment();
287
+ } catch {
288
+ // see above
289
+ }
290
+ await runStatusPoll();
291
+ }
292
+
293
+ // False when this flow has already taken over what happens next - the cart turned out to be paid and is
294
+ // being confirmed, or it has expired - so the caller must not route the shopper anywhere else.
295
+ async function releaseAbandonedAttempt(failure?: PaymentSdkResult): Promise<boolean> {
296
+ // Already cleared by an earlier call for this same attempt (e.g. a second Peach callback after the first
297
+ // already abandoned it) - nothing left to tell the server about.
298
+ if (!paymentResult) return await deps.reloadCart();
299
+
300
+ const abandoned = await abandonPaymentAttempt(paymentResult.checkoutId, failure);
301
+ if (abandoned === 'settled') {
302
+ deps.setView('result');
303
+ await runStatusPoll();
304
+ return false;
305
+ }
306
+
307
+ if (abandoned === 'abandoned') {
308
+ paymentResult = null;
309
+ forgetPaymentAttempt();
310
+ }
311
+
312
+ // The cart is Open again, or the abandon call itself failed and the shopper is being routed back to
313
+ // contact/result regardless. payment:ended tells CartExpiryGuard to stop treating a payment as in flight -
314
+ // without it the guard never prompts or expires again for the rest of the page's life - and goes out last so
315
+ // the guard re-checks the deadline it ends up with. But it is only true when the attempt is actually over:
316
+ // released here, or the cart gone regardless (a 401 on the reload - the payment window had already run out).
317
+ // After a failed abandon the server still holds the cart awaiting payment for this attempt, and the guard's
318
+ // own timeout fallback must stay armed to finish the job, so the guard is told nothing.
319
+ const reloaded = await deps.reloadCart();
320
+ if (reloaded) deps.notifyCartUpdated(deps.getCart());
321
+ if (abandoned === 'abandoned' || !reloaded) postMessage({ type: 'payment:ended' });
322
+ return reloaded;
323
+ }
324
+
325
+ // Back to the contact form rather than straight to 'payment'. Every outcome that offers Try Again is one
326
+ // where nothing was charged, and in all but one of them the attempt has already been released (a decline
327
+ // here, a Peach-reported expiry or error in onPaymentComplete) - so there is no card form left to return to:
328
+ // Peach's SDK will not re-render a checkoutId it has unmounted, and a checkout that reached a decline is
329
+ // finished on Peach's side regardless. Contact is where payNow creates a genuinely new one, with the
330
+ // shopper's details still filled in (they live in the parent component, not in ContactForm) so paying again -
331
+ // on another card, if that is what the decline was about - is one click away. The exception needs no handling
332
+ // of its own: after an abandon the server refused, the attempt is still open and payNow's own paymentResult
333
+ // short-cut resumes that same one from here, which is exactly what it is there for.
334
+ function retry(): void {
335
+ deps.setView('contact');
336
+ }
337
+
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
+ // 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, or a decline); 'waiting' means the backend has not finished and another
368
+ // read is worth making.
369
+ async function pollOnce(signal?: AbortSignal): Promise<'settled' | 'waiting'> {
370
+ let status;
371
+ try {
372
+ status = await deps.getApi().getCartStatus();
373
+ } catch (e) {
374
+ // Checked on this path too: an aborted poll must not write even a 'pending' over the outcome of the
375
+ // attempt that superseded it.
376
+ if (signal?.aborted) return 'settled';
377
+ // A cart the server no longer has is not something to pay for again - by this point it is a paid cart it
378
+ // cannot show us. Anything else says nothing about the payment (a dropped connection, a gateway blip),
379
+ // so the loop simply tries again; only running out of time settles it.
380
+ if (isInvalidCart(e)) {
381
+ confirmOutcome = 'pending';
382
+ return 'settled';
383
+ }
384
+ return 'waiting';
385
+ }
386
+
387
+ // Checked after the await, not just before it: a retry or an unmount can land while this request is in
388
+ // flight, and everything below writes state - including firing onOrderConfirmed, which the host must not
389
+ // receive twice for one order.
390
+ if (signal?.aborted) return 'settled';
391
+
392
+ if (status.cartStatus === 'confirmed') {
393
+ recordConfirmation(status.confirmResult ?? { items: [] });
394
+ return 'settled';
395
+ }
396
+
397
+ // A settled non-success attempt. The server has already asked Peach directly if no webhook arrived (Peach
398
+ // sends none for a card declined inside a checkout), so this is a real answer rather than a guess, and the
399
+ // cart is payable again - nothing was captured.
400
+ if (status.paymentOutcome === 'failed' || status.paymentOutcome === 'abandoned') {
401
+ confirmOutcome = 'notCharged';
402
+ // Clears the spent Peach checkout so Try Again mints a new one rather than reopening a dead card form,
403
+ // and re-reads the cart's remaining time. The server has already resolved this attempt itself, so there
404
+ // is nothing left to report to it.
405
+ await releaseDeclinedAttempt();
406
+ return 'settled';
407
+ }
408
+
409
+ // Everything else - awaitingPaymentConfirmation, paid, or confirming - resolves on its own.
410
+ return 'waiting';
411
+ }
412
+
413
+ function recordConfirmation(result: CheckoutCartConfirmResultDto): void {
414
+ // An empty item list is not a success - a confirmed cart always reports one result per item, so nothing to
415
+ // check means the result is not the shape we expect and must not read as "all good".
416
+ const allOk = result.items.length > 0 && result.items.every((i) => i.statusCode >= 200 && i.statusCode < 300);
417
+ confirmOutcome = allOk ? 'success' : 'partial';
418
+ // Notifies the host (order value/currency for analytics, clearing the stored cart token) without closing
419
+ // the modal - the shopper still needs to see the success screen, and only dismisses it themselves via
420
+ // ResultView's Done button, which is the one thing that posts modal:close.
421
+ if (allOk) {
422
+ deps.onOrderConfirmed();
423
+ }
424
+ }
425
+
426
+ // Runs after every terminal SDK outcome, and would run just as correctly if the SDK said nothing at all: the
427
+ // cart's own status is the single source of truth, so the browser never has to guess whether money moved.
428
+ // Timing out is not a failure - it means the backend has not finished yet, which is what the pending screen
429
+ // is for. The webhook (or the stuck-payment sweep behind it) settles the cart either way, whether or not this
430
+ // tab is still open to see it.
431
+ async function runStatusPoll(): Promise<void> {
432
+ isConfirming = true;
433
+ confirmOutcome = null;
434
+
435
+ const deadline = Date.now() + StatusPollTimeoutMs;
436
+ const poll = new AbortController();
437
+ activePoll?.abort();
438
+ activePoll = poll;
439
+
440
+ while (!poll.signal.aborted) {
441
+ if (await pollOnce(poll.signal) === 'settled') break;
442
+
443
+ if (Date.now() + StatusPollIntervalMs > deadline) {
444
+ confirmOutcome = 'pending';
445
+ break;
446
+ }
447
+
448
+ await pollDelay(StatusPollIntervalMs, poll.signal);
449
+ }
450
+
451
+ // A poll superseded by a newer one (a retry) must not write its own stale answer over it.
452
+ if (!poll.signal.aborted) {
453
+ isConfirming = false;
454
+ activePoll = null;
455
+ }
456
+ }
457
+
458
+ function pollDelay(ms: number, signal: AbortSignal): Promise<void> {
459
+ return new Promise((resolve) => {
460
+ const timer = setTimeout(resolve, ms);
461
+ signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
462
+ });
463
+ }
464
+
465
+ // A single on-demand recheck for the 'pending' screen's "Check Again" button - deliberately not another full
466
+ // runStatusPoll, which would re-impose its own wait on someone who is already actively engaged and can just
467
+ // click again.
468
+ async function checkStatusAgain(): Promise<void> {
469
+ isConfirming = true;
470
+ if (await pollOnce() === 'waiting') {
471
+ confirmOutcome = 'pending';
472
+ }
473
+ isConfirming = false;
474
+ }
475
+
476
+ // A straight readout of confirmOutcome onto ResultView's own outcome union, which now carries one of each.
477
+ // 'partial' - the payment succeeded but this gateway could not confirm every item - keeps an outcome of its
478
+ // own the whole way through: it is a real problem worth surfacing distinctly from 'pending' (which resolves
479
+ // on its own), and it must never reach the shopper as "Payment Failed", because their card was charged.
480
+ // ResultView owns the wording for it, as it already did for 'successful' and 'pending'.
481
+ const resultStatus = $derived<PaymentStatus | null>(
482
+ isConfirming
483
+ ? null
484
+ : confirmOutcome === 'success'
485
+ ? { outcome: 'successful' as const }
486
+ : confirmOutcome === 'pending'
487
+ ? { outcome: 'pending' as const }
488
+ : confirmOutcome === 'partial'
489
+ ? { outcome: 'partial' as const }
490
+ : confirmOutcome === 'notCharged'
491
+ ? {
492
+ outcome: 'failed' as const,
493
+ resultDescription: 'Your payment could not be completed. Please try again.',
494
+ retryPayment: true,
495
+ }
496
+ : null,
497
+ );
498
+
499
+ return {
500
+ get paymentResult() {
501
+ return paymentResult;
502
+ },
503
+ get isPaying() {
504
+ return isPaying;
505
+ },
506
+ get payError() {
507
+ return payError;
508
+ },
509
+ get isConfirming() {
510
+ return isConfirming;
511
+ },
512
+ get resultStatus() {
513
+ return resultStatus;
514
+ },
515
+ payNow,
516
+ onPaymentComplete,
517
+ onPaymentTimedOut,
518
+ resumeInterruptedPayment,
519
+ retry,
520
+ checkStatusAgain,
521
+ dispose: () => activePoll?.abort(),
522
+ };
523
+ }
@@ -18,11 +18,23 @@ export type CheckoutCartItemDetailDto = S['CheckoutCartItemDetailDto'];
18
18
  export type CheckoutAddCartItemDto = S['CheckoutAddCartItemDto'];
19
19
  export type CheckoutCartItemAddedDto = S['CheckoutCartItemAddedDto'];
20
20
  export type CheckoutCartPaymentInitiationDto = S['CheckoutCartPaymentInitiationDto'];
21
+ export type CheckoutCartStatusDto = S['CheckoutCartStatusDto'];
21
22
  export type CheckoutCartConfirmResultDto = S['CheckoutCartConfirmResultDto'];
22
23
  export type CheckoutCartConfirmItemResultDto = S['CheckoutCartConfirmItemResultDto'];
23
24
  export type OctoContact = S['OctoContact'];
24
25
 
25
26
  // Client-side types
27
+
28
+ // What PaymentPage hands back when Peach's embedded SDK reaches a terminal state. The failure callbacks carry
29
+ // a payload describing what went wrong; it used to be discarded, which left the backend with no way to record
30
+ // why an attempt failed - and for a card declined inside the checkout, where Peach sends no webhook, that
31
+ // payload is the only place the decline's own code exists at all.
32
+ export interface PaymentSdkResult {
33
+ status: 'completed' | 'cancelled' | 'expired' | 'error';
34
+ resultCode?: string;
35
+ description?: string;
36
+ }
37
+
26
38
  // 'partial' is its own outcome rather than a kind of 'failed' because the shopper's card WAS charged: the
27
39
  // payment succeeded and only some of the booking's items could be confirmed afterwards. It gets a heading of
28
40
  // its own in ResultView, since "Payment Failed" printed over the words "Your payment succeeded" invites