@12-apps/payments-frontend 3.20.0 → 3.20.1

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.
@@ -100,7 +100,7 @@ function useCardPublicKey(
100
100
  }
101
101
 
102
102
  /** Everything the card view renders — all checkout state + the submit handler. */
103
- interface CardCheckout {
103
+ export interface CardCheckout {
104
104
  savedCards: SavedCard[];
105
105
  selection: string;
106
106
  setSelection: (id: string) => void;
@@ -117,9 +117,16 @@ interface CardCheckout {
117
117
  errorCode: string | null;
118
118
  submitting: boolean;
119
119
  submitted: boolean;
120
+ /**
121
+ * The last status poll failed. TRANSIENT (FUT-1144): the wait carries on at a
122
+ * backed-off cadence and this clears on the next success, so the view shows
123
+ * it as "still trying" beside {@link pollCheckAgain} rather than as an end.
124
+ */
120
125
  pollError: string | null;
121
- /** The healthy-poll cap elapsed while still AWAITING (FUT-191 bounded wait). */
126
+ /** The bounded AWAITING wait elapsed (FUT-191, now wall-clock — FUT-1144). */
122
127
  pollTimedOut: boolean;
128
+ /** Ask now and restart the wait — the buyer's "verificar de novo". */
129
+ pollCheckAgain: () => void;
123
130
  handlePay: () => Promise<void>;
124
131
  }
125
132
 
@@ -142,14 +149,20 @@ type CardSubmit = Pick<
142
149
  | "errorCode"
143
150
  | "pollError"
144
151
  | "pollTimedOut"
152
+ | "pollCheckAgain"
145
153
  | "handlePay"
146
154
  >;
147
155
 
148
156
  /**
149
- * Healthy-poll cap for the card AWAITING wait: 36 polls 90 s at the 2500 ms
150
- * default interval (FUT-191). PIX passes no cap and keeps today's behavior.
157
+ * The card AWAITING wait, bounded in WALL TIME: 90 s (FUT-191, FUT-1144).
158
+ *
159
+ * It was 36 healthy polls, which is the same 90 s at the default 2500 ms
160
+ * interval and an unbounded wait at any other — including the one that
161
+ * mattered, where every poll is FAILING and the healthy count never moves.
162
+ * PIX passes no bound at all and keeps today's behavior: its charge expires
163
+ * server-side and comes back as a terminal EXPIRED.
151
164
  */
152
- const CARD_AWAITING_POLL_CAP = 36;
165
+ const CARD_AWAITING_WAIT_MS = 90_000;
153
166
 
154
167
  /**
155
168
  * Hand the buyer to the provider's authentication page (FUT-698) — Stripe's
@@ -210,6 +223,31 @@ async function resolveInstruments(input: {
210
223
  );
211
224
  }
212
225
 
226
+ /**
227
+ * The card's status wait, named the way the view reads it.
228
+ *
229
+ * Its own function so `useCardSubmit` stays inside the size gate, and so the
230
+ * one decision here — this wait is bounded in WALL TIME — sits beside the
231
+ * constant that states it rather than inside a submit machine.
232
+ */
233
+ function useCardWait(
234
+ orderId: string,
235
+ submitted: boolean,
236
+ intervalMs: number,
237
+ ): {
238
+ status: OrderStatus | null;
239
+ pollError: string | null;
240
+ pollTimedOut: boolean;
241
+ pollCheckAgain: () => void;
242
+ } {
243
+ const { status, error, timedOut, checkAgain } = usePaymentPolling(orderId, {
244
+ enabled: submitted,
245
+ intervalMs,
246
+ maxWaitMs: CARD_AWAITING_WAIT_MS,
247
+ });
248
+ return { status, pollError: error, pollTimedOut: timedOut, pollCheckAgain: checkAgain };
249
+ }
250
+
213
251
  /**
214
252
  * The submit state machine (FUT-58): validate → tokenize (self-heal) → charge →
215
253
  * poll for the async confirmation, bubbling the terminal status up via
@@ -235,11 +273,7 @@ function useCardSubmit(
235
273
  const navigate = useCheckoutNavigate();
236
274
  const cardCopy = useCheckoutCopy().card;
237
275
 
238
- const { status, error: pollError, timedOut: pollTimedOut } = usePaymentPolling(order.orderId, {
239
- enabled: submitted,
240
- intervalMs: pollIntervalMs,
241
- maxHealthyPolls: CARD_AWAITING_POLL_CAP,
242
- });
276
+ const { status, ...wait } = useCardWait(order.orderId, submitted, pollIntervalMs);
243
277
 
244
278
  useEffect(() => {
245
279
  if (status && status !== "AWAITING_PAYMENT") onResolved(status);
@@ -293,7 +327,7 @@ function useCardSubmit(
293
327
  else setSubmitted(true);
294
328
  };
295
329
 
296
- return { submitting, submitted, error, errorCode, pollError, pollTimedOut, handlePay };
330
+ return { submitting, submitted, error, errorCode, ...wait, handlePay };
297
331
  }
298
332
 
299
333
  /**
@@ -147,15 +147,19 @@ function handOverToProvider(
147
147
  * confirmation is at most 2.5 s late, and an abandoned checkout costs ~126
148
148
  * polls instead of the 360 a flat 2.5 s would have.
149
149
  *
150
- * The three constants are ONE decision — 48 × 2.5 s + 78 × 10 s ≈ 15 min — so
151
- * the cap is derived rather than typed, and cannot drift from the comment.
152
- *
153
150
  * Fifteen minutes because by then a webhook that was ever coming has come. Past
154
151
  * that the answer will not change while the buyer watches: the scheduled
155
152
  * reconciliation is what rescues a genuinely late one, and it does that whether
156
153
  * the tab is open or not.
157
154
  *
158
- * The card wait is bounded at 90 s (`CARD_AWAITING_POLL_CAP`) because a card
155
+ * The BOUND is that wall-clock window, not a poll count (FUT-1144). The two are
156
+ * the same number for a healthy wait — 48 × 2.5 s + 78 × 10 s = 15 min — and
157
+ * they part company for the wait that needed bounding: a poll that FAILS
158
+ * incremented nothing, so a connection that never came back left this screen
159
+ * asking, and spinning, with no end at all. A clock cannot be stopped by the
160
+ * failure it is measuring.
161
+ *
162
+ * The card wait is bounded at 90 s (`CARD_AWAITING_WAIT_MS`) because a card
159
163
  * authorises inline and a buyer is holding their phone. This leg is the other
160
164
  * shape: the buyer has already been off to another site and back, and may
161
165
  * legitimately still be finishing there.
@@ -165,8 +169,7 @@ const HOSTED_RESUME_SLOW_MS = 10_000;
165
169
  /** Two minutes at the fast rate, before the wait is worth economising on. */
166
170
  const HOSTED_RESUME_FAST_POLLS = (2 * 60_000) / HOSTED_RESUME_FAST_MS;
167
171
  /** Thirteen more at the slow one — 15 minutes all told. */
168
- const HOSTED_RESUME_POLL_CAP =
169
- HOSTED_RESUME_FAST_POLLS + (13 * 60_000) / HOSTED_RESUME_SLOW_MS;
172
+ const HOSTED_RESUME_WINDOW_MS = 15 * 60_000;
170
173
 
171
174
  /**
172
175
  * The leg of checkout that resumes after a hosted provider sent the buyer back
@@ -184,21 +187,56 @@ const HOSTED_RESUME_POLL_CAP =
184
187
  * had no way to reach a terminal state, because the ORDER has none: expiry is
185
188
  * PIX-only, and a redirect charge carries no QR window to lapse. `timedOut` is
186
189
  * what the screen says instead of spinning.
190
+ *
191
+ * `error` is the half that was dropped on the floor (FUT-1144), and dropping it
192
+ * is what made this leg the SILENT one. The poll below has always been able to
193
+ * fail; this hook returned `status` and `timedOut` and nothing else, so a leg
194
+ * whose every request was failing looked identical to one still waiting — the
195
+ * spinner, forever, with the reason a `console`-less browser away. It is
196
+ * surfaced now, together with the wait's own `checkAgain`, because a screen that
197
+ * says "we cannot reach the payment" and offers nothing to press is only half
198
+ * of an answer.
187
199
  */
188
200
  function useHostedResume(tenantSlug?: string): {
189
201
  order: CheckoutOrder | null;
190
202
  status: OrderStatus | null;
191
203
  timedOut: boolean;
204
+ error: string | null;
205
+ checkAgain: () => void;
192
206
  } {
193
207
  const [order] = useState(() => takeHostedOrder(tenantSlug));
194
- const { status, timedOut } = usePaymentPolling(order?.orderId ?? null, {
208
+ const { status, timedOut, error, checkAgain } = usePaymentPolling(order?.orderId ?? null, {
195
209
  enabled: Boolean(order),
196
210
  intervalMs: HOSTED_RESUME_FAST_MS,
197
211
  slowAfterPolls: HOSTED_RESUME_FAST_POLLS,
198
212
  slowIntervalMs: HOSTED_RESUME_SLOW_MS,
199
- maxHealthyPolls: HOSTED_RESUME_POLL_CAP,
213
+ maxWaitMs: HOSTED_RESUME_WINDOW_MS,
200
214
  });
201
- return { order, status, timedOut };
215
+ return { order, status, timedOut, error, checkAgain };
216
+ }
217
+
218
+ /**
219
+ * What the resumed leg contributes to the controller's surface.
220
+ *
221
+ * `resumeTimedOut` is only ever true on that leg — `useHostedResume` is the
222
+ * sole caller that bounds its wait, and a buyer who never left has a card or
223
+ * PIX view reporting its own. `resumeError` and `resumeCheckAgain` are the
224
+ * transient failure and the buyer's way out of it (FUT-1144).
225
+ *
226
+ * All three are inert for a checkout that never left this tab: with nothing
227
+ * parked the poll is disabled, so the error stays null, the bound never
228
+ * elapses, and the action has no wait to restart.
229
+ */
230
+ function resumeSurface(resume: ReturnType<typeof useHostedResume>): {
231
+ resumeTimedOut: boolean;
232
+ resumeError: string | null;
233
+ resumeCheckAgain: () => void;
234
+ } {
235
+ return {
236
+ resumeTimedOut: resume.timedOut,
237
+ resumeError: resume.error,
238
+ resumeCheckAgain: resume.checkAgain,
239
+ };
202
240
  }
203
241
 
204
242
  /**
@@ -338,10 +376,7 @@ export function useCheckoutController(
338
376
  return {
339
377
  step, setStep, method, setMethod, buyer, setBuyer, saveProfile, setSaveProfile,
340
378
  order, finalStatus: finalStatus ?? resume.status, creating,
341
- // Only ever true on the resumed leg: `useHostedResume` is the sole caller
342
- // that caps its polls, and a buyer who never left has a card or PIX view
343
- // reporting its own wait.
344
- resumeTimedOut: resume.timedOut,
379
+ ...resumeSurface(resume),
345
380
  createError: failure.message, errorField: failure.field, errorCode: failure.code,
346
381
  goToMenu: onExitToMenu, back, editBuyer,
347
382
  goToPayment, startPayment, payWithEmail, handleResolved, retry, completed,
@@ -1,19 +1,28 @@
1
- import { useEffect, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  import { useCheckoutClientApi } from "./client-context";
4
+ import type { Result } from "../../result";
4
5
  import { TERMINAL_STATUSES, type OrderStatus } from "./types";
5
6
 
6
7
  interface PollingOptions {
7
- /** Delay between polls (ms). */
8
+ /** Delay between successful polls (ms). */
8
9
  intervalMs?: number;
9
10
  /** Poll only while `true` (e.g. after a card charge is submitted). */
10
11
  enabled?: boolean;
11
12
  /**
12
- * Opt-in bound on successful non-terminal polls (FUT-191): stop scheduling and
13
- * report `timedOut` once this many healthy AWAITING responses arrive.
14
- * Undefined ⇒ unbounded, today's behavior (the PIX consumer passes no cap).
13
+ * WALL-CLOCK bound on the whole wait (FUT-1144): stop scheduling and report
14
+ * `timedOut` once this many milliseconds have passed since the wait began.
15
+ * Undefined ⇒ unbounded (the PIX consumer passes none — its charge expires
16
+ * server-side and comes back as a terminal EXPIRED).
17
+ *
18
+ * It used to be a count of HEALTHY polls, which measured the wrong thing in
19
+ * the only case that matters. A wait that is failing makes no healthy polls,
20
+ * so the count stood still while the clock ran: the hosted-return leg spun
21
+ * "Confirmando seu pagamento…" forever on a connection that never came back,
22
+ * because the only counter that could have stopped it was the one the
23
+ * failure had frozen. Wall time cannot be frozen by the failure it measures.
15
24
  */
16
- maxHealthyPolls?: number;
25
+ maxWaitMs?: number;
17
26
  /**
18
27
  * Opt-in BACKOFF: after this many healthy polls, keep asking at
19
28
  * {@link slowIntervalMs} instead of {@link intervalMs}.
@@ -32,8 +41,44 @@ interface PollingOptions {
32
41
  slowIntervalMs?: number;
33
42
  }
34
43
 
35
- /** Consecutive poll errors tolerated before giving up (avoids an infinite spinner). */
36
- const MAX_POLL_ERRORS = 4;
44
+ /** What a consumer reads off the wait, and the one action it can take. */
45
+ interface PaymentPollingState {
46
+ status: OrderStatus | null;
47
+ /**
48
+ * The last poll failed. TRANSIENT (FUT-1144): the wait keeps running on a
49
+ * backoff and this clears the moment a poll succeeds, so a consumer must
50
+ * render it as "we are still trying", never as "we gave up".
51
+ */
52
+ error: string | null;
53
+ /** The wall-clock bound elapsed — nothing further is scheduled. */
54
+ timedOut: boolean;
55
+ /**
56
+ * Ask NOW, and start the wait over: the buyer's own "check again". Restarts
57
+ * the clock too, so it is the way back from {@link timedOut}. A no-op once
58
+ * the order has settled — there is nothing left to ask about.
59
+ */
60
+ checkAgain: () => void;
61
+ }
62
+
63
+ /**
64
+ * The slowest a failing poll may go (FUT-1144).
65
+ *
66
+ * Consecutive failures double the delay — 2.5 s, 5 s, 10 s — and stop there.
67
+ * The cap is what keeps the recovery cheap: a shopper whose signal comes back
68
+ * during a Wi-Fi→4G handoff waits at most this long to be told they paid, and
69
+ * the two re-arm events below usually beat it outright.
70
+ */
71
+ const MAX_ERROR_BACKOFF_MS = 10_000;
72
+
73
+ /**
74
+ * How long a re-arm must stay quiet after the last ask.
75
+ *
76
+ * Returning to a tab commonly fires `visibilitychange` and `online` together —
77
+ * and a shopper flicking between the bank app and the store fires them again
78
+ * per trip. This collapses a burst into one request without delaying it: the
79
+ * first re-arm of a burst polls immediately, the rest fall inside the gap.
80
+ */
81
+ const REARM_QUIET_MS = 1_000;
37
82
 
38
83
  /**
39
84
  * How long before the next ask, given how many healthy polls have happened.
@@ -46,31 +91,200 @@ const MAX_POLL_ERRORS = 4;
46
91
  * poll FOLLOWING the threshold rather than one early: a wait described as "N
47
92
  * fast polls" has to actually make N of them.
48
93
  */
49
- function pollDelay(healthy: number, options: PollingOptions): number {
94
+ function healthyDelay(healthy: number, options: PollingOptions): number {
50
95
  const { intervalMs = 2500, slowAfterPolls, slowIntervalMs } = options;
51
96
  const backingOff =
52
97
  slowAfterPolls !== undefined && slowIntervalMs !== undefined && healthy >= slowAfterPolls;
53
98
  return backingOff ? slowIntervalMs : intervalMs;
54
99
  }
55
100
 
101
+ /**
102
+ * The same rule with consecutive FAILURES folded in (FUT-1144).
103
+ *
104
+ * Errors never stop the wait, they only slow it. Doubling from the healthy
105
+ * cadence is what makes a ten-second blip cost one extra beat instead of the
106
+ * whole confirmation: four failures used to be terminal, and four failures is
107
+ * what a Wi-Fi→4G handoff produces at the default 2.5 s.
108
+ *
109
+ * The cap is `MAX_ERROR_BACKOFF_MS` or the healthy cadence, whichever is
110
+ * larger — a failing poll must never ask FASTER than a succeeding one, which
111
+ * is what a bare cap would do to a consumer whose slow interval is longer.
112
+ */
113
+ function pollDelay(healthy: number, errors: number, options: PollingOptions): number {
114
+ const base = healthyDelay(healthy, options);
115
+ if (errors === 0) return base;
116
+ return Math.min(base * 2 ** (errors - 1), Math.max(base, MAX_ERROR_BACKOFF_MS));
117
+ }
118
+
119
+ /** Where a running wait writes what it has learned. */
120
+ interface PollSink {
121
+ setStatus: (status: OrderStatus) => void;
122
+ setError: (error: string | null) => void;
123
+ setTimedOut: (timedOut: boolean) => void;
124
+ }
125
+
126
+ /**
127
+ * The mutable bookkeeping one wait carries.
128
+ *
129
+ * `settled` and `stopped` are deliberately NOT the same flag. Settled means the
130
+ * order reached a terminal status and there is nothing left to ask about, ever.
131
+ * Stopped only means nothing is scheduled — which the wall clock running out
132
+ * also produces, and which the buyer's own "check again" is allowed to undo.
133
+ */
134
+ function newRun() {
135
+ return {
136
+ cancelled: false,
137
+ settled: false,
138
+ stopped: false,
139
+ inFlight: false,
140
+ errors: 0,
141
+ healthy: 0,
142
+ startedAt: 0,
143
+ askedAt: 0,
144
+ timer: undefined as ReturnType<typeof setTimeout> | undefined,
145
+ };
146
+ }
147
+
148
+ /** The handle the hook holds on one running wait. */
149
+ interface PollLoop {
150
+ /** Reset the clock and the counters, then ask immediately. */
151
+ restart: () => void;
152
+ /** Ask immediately, keeping the clock — the re-arm events' entry point. */
153
+ poke: () => void;
154
+ /** Tear down: nothing further is scheduled and nothing further is written. */
155
+ stop: () => void;
156
+ }
157
+
158
+ /**
159
+ * One self-scheduling wait, with no React in it.
160
+ *
161
+ * A `setTimeout` chain rather than an interval, so requests never overlap; the
162
+ * mutable counters live in one object because the flakiness gate's
163
+ * `no-global-state-mutation` is about exactly this shape, and because the
164
+ * whole loop has to be cancellable from a React cleanup.
165
+ */
166
+ function createPollLoop(
167
+ ask: () => Promise<Result<OrderStatus>>,
168
+ options: PollingOptions,
169
+ sink: PollSink,
170
+ ): PollLoop {
171
+ const run = newRun();
172
+
173
+ const clearPending = (): void => {
174
+ if (run.timer !== undefined) clearTimeout(run.timer);
175
+ run.timer = undefined;
176
+ };
177
+
178
+ const outOfTime = (delay: number): boolean =>
179
+ options.maxWaitMs !== undefined && Date.now() - run.startedAt + delay >= options.maxWaitMs;
180
+
181
+ const tick = async (): Promise<void> => {
182
+ if (run.cancelled || run.settled || run.inFlight) return;
183
+ run.inFlight = true;
184
+ run.askedAt = Date.now();
185
+ const result = await ask();
186
+ run.inFlight = false;
187
+ if (run.cancelled || run.settled) return;
188
+ if (result.ok) {
189
+ run.errors = 0;
190
+ sink.setError(null);
191
+ sink.setStatus(result.data);
192
+ if (TERMINAL_STATUSES.includes(result.data)) {
193
+ run.settled = true;
194
+ run.stopped = true;
195
+ return;
196
+ }
197
+ run.healthy += 1;
198
+ } else {
199
+ run.errors += 1;
200
+ sink.setError(result.error);
201
+ }
202
+ const delay = pollDelay(run.healthy, run.errors, options);
203
+ if (outOfTime(delay)) {
204
+ run.stopped = true;
205
+ sink.setTimedOut(true);
206
+ return;
207
+ }
208
+ clearPending();
209
+ run.timer = setTimeout(() => void tick(), delay);
210
+ };
211
+
212
+ return {
213
+ restart: (): void => {
214
+ if (run.cancelled || run.settled) return;
215
+ clearPending();
216
+ run.stopped = false;
217
+ run.errors = 0;
218
+ run.healthy = 0;
219
+ run.startedAt = Date.now();
220
+ sink.setTimedOut(false);
221
+ sink.setError(null);
222
+ void tick();
223
+ },
224
+ poke: (): void => {
225
+ if (run.cancelled || run.stopped || run.inFlight) return;
226
+ if (Date.now() - run.askedAt < REARM_QUIET_MS) return;
227
+ clearPending();
228
+ void tick();
229
+ },
230
+ stop: (): void => {
231
+ run.cancelled = true;
232
+ clearPending();
233
+ },
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Ask again the moment the shopper could plausibly have an answer for us.
239
+ *
240
+ * Those two moments are exactly the ones this bug was reported from: a buyer
241
+ * switching to their bank app (iOS aborts the in-flight fetches, and the tab
242
+ * comes back `visible`), and a handset moving between Wi-Fi and 4G (`online`).
243
+ * Waiting out the backoff after either is time spent not telling somebody
244
+ * their payment landed.
245
+ *
246
+ * Guarded for a host with no DOM — this package is imported by SSR frames, and
247
+ * a hook that throws at import time takes the whole checkout with it.
248
+ */
249
+ function listenForRearm(poke: () => void): () => void {
250
+ if (typeof document === "undefined" || typeof window === "undefined") {
251
+ return (): void => undefined;
252
+ }
253
+ const onVisible = (): void => {
254
+ if (document.visibilityState === "visible") poke();
255
+ };
256
+ document.addEventListener("visibilitychange", onVisible);
257
+ window.addEventListener("online", poke);
258
+ return (): void => {
259
+ document.removeEventListener("visibilitychange", onVisible);
260
+ window.removeEventListener("online", poke);
261
+ };
262
+ }
263
+
56
264
  /**
57
265
  * Poll an order's payment status until it reaches a terminal state.
58
266
  *
59
- * Uses a self-scheduling `setTimeout` (never overlapping requests) that stops as
60
- * soon as the order is PAID/FAILED/EXPIRED, and tears down on unmount or when the
61
- * order id changes. This is the client half of the async confirmation the
62
- * PagSeguro webhook drives on the server.
267
+ * Stops as soon as the order is PAID/FAILED/EXPIRED, when the wall-clock bound
268
+ * elapses, and on unmount or an order-id change. This is the client half of the
269
+ * async confirmation the provider's webhook drives on the server.
270
+ *
271
+ * A FAILED POLL IS NOT AN ENDING (FUT-1144). It slows the wait and is reported
272
+ * as `error` so a screen can say so, and the next success clears it. The wait
273
+ * ended for four consecutive errors once — ~10 s of no signal — and left a PIX
274
+ * QR under a red alert with no retry, a card spinner replaced by one, and the
275
+ * hosted return spinning forever. None of the three could recover on its own,
276
+ * which is what made a blip cost a payment nobody was ever told about.
63
277
  */
64
278
  export function usePaymentPolling(
65
279
  orderId: string | null,
66
280
  {
67
281
  intervalMs = 2500,
68
282
  enabled = true,
69
- maxHealthyPolls,
283
+ maxWaitMs,
70
284
  slowAfterPolls,
71
285
  slowIntervalMs,
72
286
  }: PollingOptions = {},
73
- ): { status: OrderStatus | null; error: string | null; timedOut: boolean } {
287
+ ): PaymentPollingState {
74
288
  const [status, setStatus] = useState<OrderStatus | null>(null);
75
289
  const [error, setError] = useState<string | null>(null);
76
290
  const [timedOut, setTimedOut] = useState(false);
@@ -78,60 +292,32 @@ export function usePaymentPolling(
78
292
  // belongs in the deps below rather than being read out of a ref: a checkout
79
293
  // re-pointed at another mount must re-poll against THAT one.
80
294
  const client = useCheckoutClientApi();
295
+ // The live wait, so the returned action stays stable across renders while
296
+ // still reaching whichever loop the effect currently owns.
297
+ const loop = useRef<PollLoop | null>(null);
81
298
 
82
299
  useEffect(() => {
83
- if (!orderId || !enabled) {
84
- return;
85
- }
300
+ if (!orderId || !enabled) return undefined;
86
301
 
87
- let cancelled = false;
88
- let timer: ReturnType<typeof setTimeout> | undefined;
89
- let errorCount = 0;
90
- let healthyCount = 0;
91
- setTimedOut(false);
92
- setError(null);
93
-
94
- const tick = async (): Promise<void> => {
95
- const result = await client.getStatus(orderId);
96
- if (cancelled) {
97
- return;
98
- }
99
- if (result.ok) {
100
- errorCount = 0;
101
- setStatus(result.data);
102
- if (TERMINAL_STATUSES.includes(result.data)) {
103
- return; // terminal — stop polling
104
- }
105
- healthyCount += 1;
106
- if (maxHealthyPolls !== undefined && healthyCount >= maxHealthyPolls) {
107
- // A healthy-but-still-AWAITING stream never trips the error cap, so
108
- // bound it separately (FUT-191): stop scheduling and let the consumer
109
- // show a "taking longer" state instead of an infinite spinner.
110
- setTimedOut(true);
111
- return;
112
- }
113
- } else {
114
- errorCount += 1;
115
- if (errorCount >= MAX_POLL_ERRORS) {
116
- // Give up rather than spin forever; surface the error to the consumer.
117
- setError(result.error);
118
- return;
119
- }
120
- }
121
- timer = setTimeout(() => {
122
- void tick();
123
- }, pollDelay(healthyCount, { intervalMs, slowAfterPolls, slowIntervalMs }));
124
- };
125
-
126
- void tick();
302
+ const running = createPollLoop(
303
+ () => client.getStatus(orderId),
304
+ { intervalMs, maxWaitMs, slowAfterPolls, slowIntervalMs },
305
+ { setStatus, setError, setTimedOut },
306
+ );
307
+ loop.current = running;
308
+ running.restart();
309
+ const unlisten = listenForRearm(running.poke);
127
310
 
128
311
  return () => {
129
- cancelled = true;
130
- if (timer) {
131
- clearTimeout(timer);
132
- }
312
+ unlisten();
313
+ running.stop();
314
+ loop.current = null;
133
315
  };
134
- }, [orderId, intervalMs, enabled, maxHealthyPolls, slowAfterPolls, slowIntervalMs, client]);
316
+ }, [orderId, intervalMs, enabled, maxWaitMs, slowAfterPolls, slowIntervalMs, client]);
317
+
318
+ const checkAgain = useCallback(() => {
319
+ loop.current?.restart();
320
+ }, []);
135
321
 
136
- return { status, error, timedOut };
322
+ return { status, error, timedOut, checkAgain };
137
323
  }
@@ -36,9 +36,16 @@ export interface WalletCharge {
36
36
  errorCode: string | null;
37
37
  /** The charge is unresolved: no pay control may render (FUT-563). */
38
38
  unresolved: boolean;
39
+ /**
40
+ * The last status poll failed. TRANSIENT (FUT-1144): the wait carries on at a
41
+ * backed-off cadence and this clears on the next success, so the pane shows
42
+ * it as "still trying" beside {@link pollCheckAgain} rather than as an end.
43
+ */
39
44
  pollError: string | null;
40
- /** The healthy-poll cap elapsed while still AWAITING (FUT-191). */
45
+ /** The bounded AWAITING wait elapsed (FUT-191, now wall-clock FUT-1144). */
41
46
  pollTimedOut: boolean;
47
+ /** Ask now and restart the wait — the buyer's "verificar de novo". */
48
+ pollCheckAgain: () => void;
42
49
  /**
43
50
  * Charge the wallet's key. The button calls this once the sheet resolves.
44
51
  * Resolves `true` when the charge was ACCEPTED — paid, confirming, or
@@ -49,10 +56,11 @@ export interface WalletCharge {
49
56
  }
50
57
 
51
58
  /**
52
- * Healthy-poll cap for the wallet AWAITING wait — the card path's own cap
53
- * (FUT-191): 36 polls 90 s at the 2500 ms default interval.
59
+ * The wallet AWAITING wait — the card path's own bound (FUT-191): 90 s, in WALL
60
+ * TIME rather than healthy polls (FUT-1144, and see `CARD_AWAITING_WAIT_MS` for
61
+ * why a count of the polls that SUCCEEDED cannot bound a wait that is failing).
54
62
  */
55
- const WALLET_AWAITING_POLL_CAP = 36;
63
+ const WALLET_AWAITING_WAIT_MS = 90_000;
56
64
 
57
65
  /** The wallet charge state machine. See the module comment. */
58
66
  export function useWalletCharge(
@@ -67,10 +75,15 @@ export function useWalletCharge(
67
75
  const client = useCheckoutClientApi();
68
76
  const navigate = useCheckoutNavigate();
69
77
 
70
- const { status, error: pollError, timedOut: pollTimedOut } = usePaymentPolling(order.orderId, {
78
+ const {
79
+ status,
80
+ error: pollError,
81
+ timedOut: pollTimedOut,
82
+ checkAgain: pollCheckAgain,
83
+ } = usePaymentPolling(order.orderId, {
71
84
  enabled: phase === "polling",
72
85
  intervalMs: pollIntervalMs,
73
- maxHealthyPolls: WALLET_AWAITING_POLL_CAP,
86
+ maxWaitMs: WALLET_AWAITING_WAIT_MS,
74
87
  });
75
88
 
76
89
  useEffect(() => {
@@ -124,6 +137,7 @@ export function useWalletCharge(
124
137
  unresolved: errorCode === UNRESOLVED_CODE,
125
138
  pollError,
126
139
  pollTimedOut,
140
+ pollCheckAgain,
127
141
  payWithKey,
128
142
  };
129
143
  }
@@ -74,8 +74,23 @@ export interface PaymentStatusCopy {
74
74
  failed: StatusOutcomeCopy;
75
75
  expired: StatusOutcomeCopy;
76
76
  awaitingTimedOut: StatusOutcomeCopy;
77
+ /**
78
+ * The wait cannot reach the payment right now (FUT-1144) — and is STILL
79
+ * ASKING, which is the whole difference between this outcome and the one
80
+ * above it, and why the one above it wins when a screen somehow has both. The
81
+ * resumed hosted return used to render neither: its poll could fail forever
82
+ * and the screen went on saying "isso costuma levar alguns segundos" under a
83
+ * spinner, so the one leg with no PIX or card view of its own was also the
84
+ * one that never mentioned a problem.
85
+ */
86
+ awaitingUnreachable: StatusOutcomeCopy;
77
87
  retryAction: string;
78
88
  regenerateAction: string;
89
+ /**
90
+ * Ask now, rather than waiting for the next automatic poll — and, once the
91
+ * wait has run out, the only thing that starts it again.
92
+ */
93
+ checkAgainAction: string;
79
94
  backAction: string;
80
95
  /** The paid receipt's three row labels. */
81
96
  amountLabel: string;