@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.
@@ -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
- // declined attempt, and polling the cart's status until it settles. None of it needs the cart's own item list,
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: 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.
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 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.
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 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.
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
- if (status.paymentOutcome === 'failed' || status.paymentOutcome === 'abandoned') {
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
- await releaseDeclinedAttempt();
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 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
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 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'> {
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
- if (failure) {
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
- // 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.
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
- // 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;
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 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.
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(failure?: PaymentSdkResult): Promise<boolean> {
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, failure);
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, 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.
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, or a decline); 'waiting' means the backend has not finished and another
368
- // read is worth making.
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
- // 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') {
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
- // 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();
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;