@code-collective/booking-widget 1.0.15 → 1.0.16
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 +75 -48
- package/README.md +506 -500
- package/dist/booking-widget.js +28 -51
- package/dist/booking-widget.min.js +2 -2
- package/dist/booking-widget.umd.cjs +2 -2
- package/package.json +1 -1
- package/src/lib/api.ts +1 -28
- package/src/lib/checkout-payment-flow.svelte.ts +59 -103
- package/src/lib/generated-types.ts +0 -82
|
@@ -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';
|
|
@@ -89,19 +89,18 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
89
89
|
// A reload mid-payment: the server still holds this cart in AwaitingPaymentConfirmation for the Peach
|
|
90
90
|
// checkout recorded before the reload, and will refuse every edit and a second payCart until that attempt
|
|
91
91
|
// 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
|
-
//
|
|
92
|
+
// server knows: the charge may have landed a moment earlier, so the cart is read once first and a settled
|
|
93
|
+
// outcome is shown exactly as it would have been without the reload. Anything short of that puts the shopper
|
|
94
|
+
// back on the same card form, whose Peach handlers then route as they do without a reload: a completed
|
|
95
|
+
// charge confirms, a cancel/expiry/error releases the checkout and reopens the cart. A card declined in
|
|
96
|
+
// between changes nothing here - it never ended Peach's checkout, so the form is still the right place.
|
|
97
97
|
// Same resume as payNow's own paymentResult short-cut.
|
|
98
98
|
async function resumeInterruptedPayment(): Promise<void> {
|
|
99
99
|
const interruptedAttempt = recallPaymentAttempt(deps.getApi().cartToken);
|
|
100
100
|
if (!interruptedAttempt) return;
|
|
101
101
|
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.
|
|
102
|
+
// Posted before the answer is known, as payNow does: CartExpiryGuard must treat the payment as in flight
|
|
103
|
+
// whichever screen it ends up on.
|
|
105
104
|
postMessage({ type: 'payment:started' });
|
|
106
105
|
if (await resumeOntoRecordedOutcome()) return;
|
|
107
106
|
deps.setView('payment');
|
|
@@ -111,14 +110,13 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
111
110
|
if (cartDeadline(deps.getCart()).getTime() <= Date.now()) void onPaymentTimedOut();
|
|
112
111
|
}
|
|
113
112
|
|
|
114
|
-
// Where a reload lands. One status read decides it: a
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// may never have submitted.
|
|
113
|
+
// Where a reload lands. One status read decides it: a confirmed cart goes straight to the result screen; a
|
|
114
|
+
// confirmation in flight joins the ordinary poll to read its outcome; a cart the server no longer has closes
|
|
115
|
+
// the modal, as a 401 does everywhere else here. Anything still unresolved - a shopper who never submitted
|
|
116
|
+
// the card, one who reloaded mid-3-D Secure, or one still working through declines on Peach's own form,
|
|
117
|
+
// none of which can be told apart from here - goes back to the card form, since that is Peach's own form for
|
|
118
|
+
// this same checkoutId rather than a fresh charge. An unreachable server is treated the same way, rather
|
|
119
|
+
// than parking them on a screen telling them not to pay again for a card they may never have submitted.
|
|
122
120
|
async function resumeOntoRecordedOutcome(): Promise<boolean> {
|
|
123
121
|
isConfirming = true;
|
|
124
122
|
deps.setView('result');
|
|
@@ -141,9 +139,13 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
141
139
|
return true;
|
|
142
140
|
}
|
|
143
141
|
|
|
144
|
-
|
|
142
|
+
// Expired while the tab was away - the sweep found a cart nobody came back to. There is no card form to
|
|
143
|
+
// return to, so this owns where the shopper lands rather than falling through to one.
|
|
144
|
+
if (status.cartStatus === 'expired') {
|
|
145
145
|
confirmOutcome = 'notCharged';
|
|
146
|
-
|
|
146
|
+
forgetPaymentAttempt();
|
|
147
|
+
paymentResult = null;
|
|
148
|
+
postMessage({ type: 'payment:ended' });
|
|
147
149
|
isConfirming = false;
|
|
148
150
|
return true;
|
|
149
151
|
}
|
|
@@ -158,24 +160,20 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
158
160
|
return false;
|
|
159
161
|
}
|
|
160
162
|
|
|
161
|
-
// Tells the server this
|
|
162
|
-
// AwaitingPaymentConfirmation, no webhook ever comes for a checkout nobody
|
|
163
|
-
//
|
|
164
|
-
//
|
|
163
|
+
// Tells the server this checkout is over so a fresh payCart will be accepted - without it the cart stays
|
|
164
|
+
// AwaitingPaymentConfirmation, no webhook ever comes for a checkout nobody paid, and the only thing a retry
|
|
165
|
+
// could do is reopen a Peach session that is already dead. 'settled' means Peach has this checkout as paid,
|
|
166
|
+
// or still in flight (PAYMENT_PENDING) - either way the cart must not be reopened, and the confirm loop
|
|
165
167
|
// already handles "not paid yet" by landing on the pending screen, so both take the same path here; 'failed'
|
|
166
168
|
// 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
|
-
|
|
169
|
+
// so payNow falls back to resuming the same still-open checkout.
|
|
170
|
+
// One endpoint for every way out: a shopper backing out, a clock running out, and Peach's own SDK reporting
|
|
171
|
+
// the checkout unusable are all the same thing to this gateway - a checkout nothing was charged on. A
|
|
172
|
+
// declined card is not among them: Peach keeps the shopper on its own form to try again, and none of that
|
|
173
|
+
// ever reaches here.
|
|
174
|
+
async function abandonPaymentAttempt(checkoutId: string): Promise<'abandoned' | 'settled' | 'failed'> {
|
|
173
175
|
try {
|
|
174
|
-
|
|
175
|
-
await deps.getApi().reportPaymentFailure(checkoutId, failure.resultCode, failure.description);
|
|
176
|
-
} else {
|
|
177
|
-
await deps.getApi().abandonPayment(checkoutId);
|
|
178
|
-
}
|
|
176
|
+
await deps.getApi().abandonPayment(checkoutId);
|
|
179
177
|
return 'abandoned';
|
|
180
178
|
} catch (e) {
|
|
181
179
|
return isPaymentSettled(e) || isPaymentPending(e) ? 'settled' : 'failed';
|
|
@@ -250,19 +248,14 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
250
248
|
}
|
|
251
249
|
|
|
252
250
|
async function onPaymentComplete(result: PaymentSdkResult): Promise<void> {
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
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.
|
|
251
|
+
// None of cancelled, expired or error is a decline - a declined card never ends Peach's checkout at all,
|
|
252
|
+
// it re-prompts inside the same form, and the shopper only comes back to us once they give up or pay. What
|
|
253
|
+
// these three mean is that this checkout is unusable: the shopper backed out, the session timed out, or
|
|
254
|
+
// the SDK itself broke. Nothing was charged, so the checkout is released server-side (a retry then mints a
|
|
255
|
+
// genuinely new one rather than reopening this dead one) - and the abandon's own Peach-status guard is
|
|
256
|
+
// what makes that safe in the case where the SDK is wrong about no charge having landed.
|
|
262
257
|
if (result.status === 'cancelled' || result.status === 'expired' || result.status === 'error') {
|
|
263
|
-
|
|
264
|
-
const failure = result.status === 'cancelled' ? undefined : result;
|
|
265
|
-
if (!(await releaseAbandonedAttempt(failure))) return;
|
|
258
|
+
if (!(await releaseAbandonedAttempt())) return;
|
|
266
259
|
}
|
|
267
260
|
|
|
268
261
|
if (result.status === 'cancelled') {
|
|
@@ -277,27 +270,21 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
277
270
|
return;
|
|
278
271
|
}
|
|
279
272
|
|
|
280
|
-
// Peach's form has reported the checkout complete, which
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
// from the webhook or the sweep instead, which is exactly what the poll is already waiting for.
|
|
273
|
+
// Peach's form has reported the checkout complete, which after the branch above can only be a charge.
|
|
274
|
+
// Nothing is asserted from here regardless: the webhook is what moves the cart to Paid, and this poll only
|
|
275
|
+
// reads the cart until it says so.
|
|
284
276
|
deps.setView('result');
|
|
285
|
-
try {
|
|
286
|
-
await deps.getApi().resolvePayment();
|
|
287
|
-
} catch {
|
|
288
|
-
// see above
|
|
289
|
-
}
|
|
290
277
|
await runStatusPoll();
|
|
291
278
|
}
|
|
292
279
|
|
|
293
280
|
// False when this flow has already taken over what happens next - the cart turned out to be paid and is
|
|
294
281
|
// being confirmed, or it has expired - so the caller must not route the shopper anywhere else.
|
|
295
|
-
async function releaseAbandonedAttempt(
|
|
282
|
+
async function releaseAbandonedAttempt(): Promise<boolean> {
|
|
296
283
|
// Already cleared by an earlier call for this same attempt (e.g. a second Peach callback after the first
|
|
297
284
|
// already abandoned it) - nothing left to tell the server about.
|
|
298
285
|
if (!paymentResult) return await deps.reloadCart();
|
|
299
286
|
|
|
300
|
-
const abandoned = await abandonPaymentAttempt(paymentResult.checkoutId
|
|
287
|
+
const abandoned = await abandonPaymentAttempt(paymentResult.checkoutId);
|
|
301
288
|
if (abandoned === 'settled') {
|
|
302
289
|
deps.setView('result');
|
|
303
290
|
await runStatusPoll();
|
|
@@ -323,49 +310,21 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
323
310
|
}
|
|
324
311
|
|
|
325
312
|
// 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.
|
|
313
|
+
// where nothing was charged and the checkout behind it has already been released (a Peach-reported expiry or
|
|
314
|
+
// error in onPaymentComplete) - so there is no card form left to return to: Peach's SDK will not re-render a
|
|
315
|
+
// checkoutId it has unmounted. Contact is where payNow creates a genuinely new checkout, with the shopper's
|
|
316
|
+
// details still filled in (they live in the parent component, not in ContactForm) so paying again is one
|
|
317
|
+
// click away. The one exception needs no handling of its own: after an abandon the server refused, the
|
|
318
|
+
// checkout is still open and payNow's own paymentResult short-cut resumes that same one from here, which is
|
|
319
|
+
// exactly what it is there for.
|
|
334
320
|
function retry(): void {
|
|
335
321
|
deps.setView('contact');
|
|
336
322
|
}
|
|
337
323
|
|
|
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
324
|
// 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
|
-
//
|
|
325
|
+
// automatic poll and the manual on-demand recheck. 'settled' has recorded the outcome (success, or a
|
|
326
|
+
// supplier-side partial failure); 'waiting' means the backend has not finished and another read is worth
|
|
327
|
+
// making.
|
|
369
328
|
async function pollOnce(signal?: AbortSignal): Promise<'settled' | 'waiting'> {
|
|
370
329
|
let status;
|
|
371
330
|
try {
|
|
@@ -394,15 +353,12 @@ export function createCheckoutPaymentFlow(deps: CheckoutPaymentFlowDeps): Checko
|
|
|
394
353
|
return 'settled';
|
|
395
354
|
}
|
|
396
355
|
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
if (status.paymentOutcome === 'failed' || status.paymentOutcome === 'abandoned') {
|
|
356
|
+
// The stuck-payment sweep released this cart: nothing was charged, nothing is coming, and the holds
|
|
357
|
+
// behind it are being cancelled. Terminal - polling on would just wait out the window.
|
|
358
|
+
if (status.cartStatus === 'expired') {
|
|
401
359
|
confirmOutcome = 'notCharged';
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
// is nothing left to report to it.
|
|
405
|
-
await releaseDeclinedAttempt();
|
|
360
|
+
forgetPaymentAttempt();
|
|
361
|
+
paymentResult = null;
|
|
406
362
|
return 'settled';
|
|
407
363
|
}
|
|
408
364
|
|
|
@@ -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;
|