@code-collective/booking-widget 1.0.12 → 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.
- package/CHANGELOG.md +48 -46
- package/README.md +500 -500
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +829 -707
- package/dist/booking-widget.min.js +24 -25
- package/dist/booking-widget.umd.cjs +5 -5
- package/package.json +58 -58
- package/src/lib/BookingProvider.svelte +21 -21
- package/src/lib/CartBar.svelte +26 -26
- package/src/lib/CartBarView.svelte +78 -78
- package/src/lib/CartExpiryGuard.svelte +416 -416
- package/src/lib/CartOverview.svelte +30 -30
- package/src/lib/CartOverviewButton.svelte +104 -104
- package/src/lib/Checkout.svelte +141 -141
- package/src/lib/CheckoutModal.css +179 -0
- package/src/lib/CheckoutModal.svelte +78 -573
- package/src/lib/CheckoutPanel.svelte +121 -121
- package/src/lib/PaymentPage.svelte +208 -191
- package/src/lib/ResultView.svelte +24 -4
- package/src/lib/TicketConfigurator.svelte +166 -166
- package/src/lib/api.ts +36 -5
- package/src/lib/booking-context.ts +33 -33
- package/src/lib/cart-overview.svelte.ts +97 -97
- package/src/lib/checkout-item-view.ts +63 -0
- package/src/lib/checkout-payment-flow.svelte.ts +523 -0
- package/src/lib/client-types.ts +22 -6
- package/src/lib/config.ts +162 -162
- package/src/lib/elements/bw-cart.svelte +49 -49
- package/src/lib/elements/bw-checkout.svelte +97 -97
- package/src/lib/elements/bw-configurator.svelte +72 -72
- package/src/lib/elements/register.ts +171 -171
- package/src/lib/elements/shared.ts +18 -18
- package/src/lib/generated-types.ts +94 -5
- package/src/lib/host.svelte.ts +336 -336
- package/src/lib/index.ts +242 -242
- package/src/lib/messages.ts +157 -157
- package/src/lib/peach-sdk.ts +86 -86
- package/src/lib/portal.ts +23 -23
|
@@ -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
|
+
}
|
package/src/lib/client-types.ts
CHANGED
|
@@ -18,22 +18,38 @@ 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
|
|
26
|
-
|
|
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
|
+
|
|
38
|
+
// 'partial' is its own outcome rather than a kind of 'failed' because the shopper's card WAS charged: the
|
|
39
|
+
// payment succeeded and only some of the booking's items could be confirmed afterwards. It gets a heading of
|
|
40
|
+
// its own in ResultView, since "Payment Failed" printed over the words "Your payment succeeded" invites
|
|
41
|
+
// exactly the wrong reaction - paying again elsewhere, or charging back a payment that did go through.
|
|
42
|
+
export type PaymentOutcome = 'successful' | 'pending' | 'partial' | 'failed' | 'cancelled';
|
|
27
43
|
|
|
28
44
|
export interface PaymentStatus {
|
|
29
45
|
outcome: PaymentOutcome;
|
|
30
46
|
resultCode?: string;
|
|
31
47
|
resultDescription?: string;
|
|
32
|
-
// Only meaningful for outcome 'failed'
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
48
|
+
// Only meaningful for outcome 'failed', and only for a 'failed' that nonetheless took the shopper's money:
|
|
49
|
+
// it must never be offered a "try again" that re-opens the card form, since they would be charged a second
|
|
50
|
+
// time for a booking that already has their money against it. Defaults true (the ordinary declined/expired/
|
|
51
|
+
// errored case, where nothing was charged and retrying is exactly correct) so existing callers need not set
|
|
52
|
+
// it. The confirm-partial case this was first introduced for now carries its own 'partial' outcome.
|
|
37
53
|
retryPayment?: boolean;
|
|
38
54
|
}
|
|
39
55
|
|