@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.
- package/package.json +2 -2
- package/src/components/checkout/card-view.tsx +30 -28
- package/src/components/checkout/checkout-flow.tsx +5 -0
- package/src/components/checkout/en-US.ts +9 -0
- package/src/components/checkout/payment-status.tsx +130 -38
- package/src/components/checkout/pix-view.tsx +34 -12
- package/src/components/checkout/pt-BR.ts +7 -0
- package/src/components/checkout/screens-copy.ts +19 -0
- package/src/components/checkout/screens-en-US.ts +4 -0
- package/src/components/checkout/screens-pt-BR.ts +2 -0
- package/src/components/checkout/stalled-wait.tsx +58 -0
- package/src/components/checkout/use-card-checkout.ts +45 -11
- package/src/components/checkout/use-checkout-controller.ts +48 -13
- package/src/components/checkout/use-payment-polling.ts +250 -64
- package/src/components/checkout/use-wallet-charge.ts +20 -6
- package/src/components/checkout/view-copy.ts +15 -0
- package/src/components/checkout/wallet-pane.tsx +17 -16
- package/src/flows/copy.ts +19 -0
- package/src/flows/screens-hosted.tsx +72 -17
|
@@ -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
|
|
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
|
-
*
|
|
150
|
-
*
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* Undefined ⇒ unbounded
|
|
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
|
-
|
|
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
|
-
/**
|
|
36
|
-
|
|
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
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
-
|
|
283
|
+
maxWaitMs,
|
|
70
284
|
slowAfterPolls,
|
|
71
285
|
slowIntervalMs,
|
|
72
286
|
}: PollingOptions = {},
|
|
73
|
-
):
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
312
|
+
unlisten();
|
|
313
|
+
running.stop();
|
|
314
|
+
loop.current = null;
|
|
133
315
|
};
|
|
134
|
-
}, [orderId, intervalMs, enabled,
|
|
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
|
|
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
|
-
*
|
|
53
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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;
|