@code-collective/booking-widget 1.0.15 → 1.0.17

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