@12-apps/payments-frontend 3.21.1 → 3.21.3
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.21.
|
|
3
|
+
"version": "3.21.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import type { Result } from "../../result";
|
|
2
|
+
import { TERMINAL_STATUSES, type OrderStatus } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ONE self-scheduling wait for a payment to settle, with no React in it.
|
|
6
|
+
*
|
|
7
|
+
* Split out of `use-payment-polling.ts` so the hook there is what it reads as:
|
|
8
|
+
* an effect that owns a loop for the length of an order id. Everything here is
|
|
9
|
+
* the loop itself — the backoff, the wall clock, the per-ask timeout and the
|
|
10
|
+
* attempt counter that lets a hung request be abandoned rather than waited on.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface PollingOptions {
|
|
14
|
+
/** Delay between successful polls (ms). */
|
|
15
|
+
intervalMs?: number;
|
|
16
|
+
/**
|
|
17
|
+
* How long one ask may hang before it is abandoned (ms). Defaults to
|
|
18
|
+
* {@link DEFAULT_ASK_TIMEOUT_MS}; consumers override it only in tests.
|
|
19
|
+
*/
|
|
20
|
+
askTimeoutMs?: number;
|
|
21
|
+
/**
|
|
22
|
+
* What the BUYER reads when an ask is abandoned for hanging.
|
|
23
|
+
*
|
|
24
|
+
* The hook passes the transport's own `copy.offline`, so a dead socket reads
|
|
25
|
+
* as the dropped connection it is. Optional only so a direct caller of
|
|
26
|
+
* `createPollLoop` need not thread copy it does not have.
|
|
27
|
+
*/
|
|
28
|
+
askTimeoutError?: string;
|
|
29
|
+
/** Poll only while `true` (e.g. after a card charge is submitted). */
|
|
30
|
+
enabled?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* WALL-CLOCK bound on the whole wait (FUT-1144): stop scheduling and report
|
|
33
|
+
* `timedOut` once this many milliseconds have passed since the wait began.
|
|
34
|
+
* Undefined ⇒ unbounded (the PIX consumer passes none — its charge expires
|
|
35
|
+
* server-side and comes back as a terminal EXPIRED).
|
|
36
|
+
*
|
|
37
|
+
* It used to be a count of HEALTHY polls, which measured the wrong thing in
|
|
38
|
+
* the only case that matters. A wait that is failing makes no healthy polls,
|
|
39
|
+
* so the count stood still while the clock ran: the hosted-return leg spun
|
|
40
|
+
* "Confirmando seu pagamento…" forever on a connection that never came back,
|
|
41
|
+
* because the only counter that could have stopped it was the one the
|
|
42
|
+
* failure had frozen. Wall time cannot be frozen by the failure it measures.
|
|
43
|
+
*/
|
|
44
|
+
maxWaitMs?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Opt-in BACKOFF: after this many healthy polls, keep asking at
|
|
47
|
+
* {@link slowIntervalMs} instead of {@link intervalMs}.
|
|
48
|
+
*
|
|
49
|
+
* A single interval cannot serve a long wait, because the two things it
|
|
50
|
+
* decides pull opposite ways. It is how fast a buyer WHO PAID learns that
|
|
51
|
+
* they did — every poll is a provider round trip, and the answer lands within
|
|
52
|
+
* seconds of the webhook — and it is also what an abandoned checkout costs
|
|
53
|
+
* for the rest of the window. Tuning one picks the other's loser: a slow
|
|
54
|
+
* interval taxes the common case (the person on this screen almost always
|
|
55
|
+
* paid) to subsidise the rare one.
|
|
56
|
+
*
|
|
57
|
+
* Splitting them costs neither. Both must be set for backoff to apply.
|
|
58
|
+
*/
|
|
59
|
+
slowAfterPolls?: number;
|
|
60
|
+
slowIntervalMs?: number;
|
|
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;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* How long ONE status ask may take before the loop stops waiting on it.
|
|
85
|
+
*
|
|
86
|
+
* Comfortably longer than any healthy round trip on a bad link, so an ordinary
|
|
87
|
+
* slow poll is never mistaken for a dead one; short enough that a socket that
|
|
88
|
+
* died silently — the iOS case this whole feature exists for — costs one
|
|
89
|
+
* backoff step rather than the rest of the wait.
|
|
90
|
+
*/
|
|
91
|
+
const DEFAULT_ASK_TIMEOUT_MS = 15_000;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Last resort only, for a host that wired the loop without `askTimeoutError`.
|
|
95
|
+
* `Result.error` is what the BUYER reads — the PIX, card and wallet panels all
|
|
96
|
+
* render it verbatim — so the hook passes the transport's own `copy.offline`
|
|
97
|
+
* down and a hang reads as the dropped connection it is.
|
|
98
|
+
*/
|
|
99
|
+
const ASK_TIMED_OUT = "timeout";
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* How long before the next ask, given how many healthy polls have happened.
|
|
103
|
+
*
|
|
104
|
+
* A pure function of the options, so it lives out here rather than inside the
|
|
105
|
+
* effect — the hook is at its size gate, and a scheduling RULE is easier to
|
|
106
|
+
* read (and to test) stated once than threaded through a closure.
|
|
107
|
+
*
|
|
108
|
+
* Reads the count AFTER the poll just made, so the slow phase begins on the
|
|
109
|
+
* poll FOLLOWING the threshold rather than one early: a wait described as "N
|
|
110
|
+
* fast polls" has to actually make N of them.
|
|
111
|
+
*/
|
|
112
|
+
function healthyDelay(healthy: number, options: PollingOptions): number {
|
|
113
|
+
const { intervalMs = 2500, slowAfterPolls, slowIntervalMs } = options;
|
|
114
|
+
const backingOff =
|
|
115
|
+
slowAfterPolls !== undefined && slowIntervalMs !== undefined && healthy >= slowAfterPolls;
|
|
116
|
+
return backingOff ? slowIntervalMs : intervalMs;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The same rule with consecutive FAILURES folded in (FUT-1144).
|
|
121
|
+
*
|
|
122
|
+
* Errors never stop the wait, they only slow it. Doubling from the healthy
|
|
123
|
+
* cadence is what makes a ten-second blip cost one extra beat instead of the
|
|
124
|
+
* whole confirmation: four failures used to be terminal, and four failures is
|
|
125
|
+
* what a Wi-Fi→4G handoff produces at the default 2.5 s.
|
|
126
|
+
*
|
|
127
|
+
* The cap is `MAX_ERROR_BACKOFF_MS` or the healthy cadence, whichever is
|
|
128
|
+
* larger — a failing poll must never ask FASTER than a succeeding one, which
|
|
129
|
+
* is what a bare cap would do to a consumer whose slow interval is longer.
|
|
130
|
+
*/
|
|
131
|
+
function pollDelay(healthy: number, errors: number, options: PollingOptions): number {
|
|
132
|
+
const base = healthyDelay(healthy, options);
|
|
133
|
+
if (errors === 0) return base;
|
|
134
|
+
return Math.min(base * 2 ** (errors - 1), Math.max(base, MAX_ERROR_BACKOFF_MS));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Where a running wait writes what it has learned. */
|
|
138
|
+
interface PollSink {
|
|
139
|
+
setStatus: (status: OrderStatus) => void;
|
|
140
|
+
setError: (error: string | null) => void;
|
|
141
|
+
setTimedOut: (timedOut: boolean) => void;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The mutable bookkeeping one wait carries.
|
|
146
|
+
*
|
|
147
|
+
* `settled` and `stopped` are deliberately NOT the same flag. Settled means the
|
|
148
|
+
* order reached a terminal status and there is nothing left to ask about, ever.
|
|
149
|
+
* Stopped only means nothing is scheduled — which the wall clock running out
|
|
150
|
+
* also produces, and which the buyer's own "check again" is allowed to undo.
|
|
151
|
+
*/
|
|
152
|
+
function newRun() {
|
|
153
|
+
return {
|
|
154
|
+
cancelled: false,
|
|
155
|
+
settled: false,
|
|
156
|
+
stopped: false,
|
|
157
|
+
inFlight: false,
|
|
158
|
+
/**
|
|
159
|
+
* Which ask is the CURRENT one.
|
|
160
|
+
*
|
|
161
|
+
* A hung request cannot be un-sent, so it is abandoned instead: every ask
|
|
162
|
+
* carries the attempt it belongs to, and a late answer from a superseded
|
|
163
|
+
* attempt is dropped. Without this, `inFlight` stayed true for as long as
|
|
164
|
+
* the socket did — and `inFlight` gates the tick, the re-arm AND the
|
|
165
|
+
* buyer's own "check again", so one dead request disabled every mechanism
|
|
166
|
+
* this loop has for recovering from a dead request.
|
|
167
|
+
*/
|
|
168
|
+
attempt: 0,
|
|
169
|
+
errors: 0,
|
|
170
|
+
healthy: 0,
|
|
171
|
+
startedAt: 0,
|
|
172
|
+
askedAt: 0,
|
|
173
|
+
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
174
|
+
/**
|
|
175
|
+
* The wall clock, as a timer rather than a check after an ask. `outOfTime`
|
|
176
|
+
* needs an ask to RETURN, and `askTimeout` stands down when its attempt is
|
|
177
|
+
* superseded — which every `poke` does. A shopper flicking to their bank
|
|
178
|
+
* app faster than `askTimeoutMs` refreshed it forever, so `maxWaitMs` never
|
|
179
|
+
* fired and the hosted return span with no error and no check-again button.
|
|
180
|
+
*/
|
|
181
|
+
deadline: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The mutable state of one wait — see {@link newRun}. */
|
|
186
|
+
type PollRun = ReturnType<typeof newRun>;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Give up WAITING on one ask — never on the wait itself.
|
|
190
|
+
*
|
|
191
|
+
* Resolves as an ordinary failed poll, so a hang costs exactly what a 500
|
|
192
|
+
* costs: the error counter rises, the backoff widens, and the next tick is
|
|
193
|
+
* scheduled. `run.attempt` is what makes it safe — if the real answer lands
|
|
194
|
+
* afterwards it belongs to a superseded attempt and is dropped.
|
|
195
|
+
*/
|
|
196
|
+
function askTimeout(
|
|
197
|
+
run: PollRun,
|
|
198
|
+
mine: number,
|
|
199
|
+
options: PollingOptions,
|
|
200
|
+
): Promise<Result<OrderStatus>> {
|
|
201
|
+
return new Promise((resolve) => {
|
|
202
|
+
setTimeout(() => {
|
|
203
|
+
if (run.attempt === mine) {
|
|
204
|
+
resolve({ ok: false, error: options.askTimeoutError ?? ASK_TIMED_OUT });
|
|
205
|
+
}
|
|
206
|
+
}, options.askTimeoutMs ?? DEFAULT_ASK_TIMEOUT_MS);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Fold one poll's answer into the run and into what the screen says.
|
|
212
|
+
*
|
|
213
|
+
* Returns whether the wait continues. A TERMINAL status is the only answer
|
|
214
|
+
* that can end it from here — the wall clock is the caller's business, because
|
|
215
|
+
* it has to be consulted against the delay it is about to sleep for.
|
|
216
|
+
*/
|
|
217
|
+
function absorb(run: PollRun, result: Result<OrderStatus>, sink: PollSink): boolean {
|
|
218
|
+
if (!result.ok) {
|
|
219
|
+
run.errors += 1;
|
|
220
|
+
sink.setError(result.error);
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
run.errors = 0;
|
|
224
|
+
sink.setError(null);
|
|
225
|
+
sink.setStatus(result.data);
|
|
226
|
+
if (TERMINAL_STATUSES.includes(result.data)) {
|
|
227
|
+
run.settled = true;
|
|
228
|
+
run.stopped = true;
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
run.healthy += 1;
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Cancel whatever tick is scheduled, if any. */
|
|
236
|
+
function clearPending(run: PollRun): void {
|
|
237
|
+
if (run.timer !== undefined) clearTimeout(run.timer);
|
|
238
|
+
run.timer = undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Cancel the wall clock, if it is armed. */
|
|
242
|
+
function clearDeadline(run: PollRun): void {
|
|
243
|
+
if (run.deadline !== undefined) clearTimeout(run.deadline);
|
|
244
|
+
run.deadline = undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The wall clock, armed once per run by `restart`, so `maxWaitMs` holds whether
|
|
249
|
+
* or not an ask ever returns. `outOfTime` stays too: it ends the wait one delay
|
|
250
|
+
* EARLIER when asks ARE returning. This is the backstop for when they are not.
|
|
251
|
+
*/
|
|
252
|
+
function armDeadline(run: PollRun, options: PollingOptions, sink: PollSink): void {
|
|
253
|
+
clearDeadline(run);
|
|
254
|
+
if (options.maxWaitMs === undefined) return;
|
|
255
|
+
run.deadline = setTimeout(() => {
|
|
256
|
+
run.deadline = undefined;
|
|
257
|
+
if (run.cancelled || run.settled) return;
|
|
258
|
+
run.stopped = true;
|
|
259
|
+
clearPending(run);
|
|
260
|
+
sink.setTimedOut(true);
|
|
261
|
+
}, options.maxWaitMs);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* One ask, bounded by {@link askTimeout} and guaranteed not to throw. `race`
|
|
266
|
+
* rejects the instant either input does, so an `ask` that throws — a host
|
|
267
|
+
* client wrapping ours — left `inFlight` true with nothing scheduled: the very
|
|
268
|
+
* wedge this file removes, through the one door the race does not close.
|
|
269
|
+
*/
|
|
270
|
+
async function askOnce(
|
|
271
|
+
ask: () => Promise<Result<OrderStatus>>,
|
|
272
|
+
run: PollRun,
|
|
273
|
+
mine: number,
|
|
274
|
+
options: PollingOptions,
|
|
275
|
+
): Promise<Result<OrderStatus>> {
|
|
276
|
+
try {
|
|
277
|
+
return await Promise.race([ask(), askTimeout(run, mine, options)]);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
const fallback = options.askTimeoutError ?? ASK_TIMED_OUT;
|
|
280
|
+
return { ok: false, error: error instanceof Error ? error.message : fallback };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Whether sleeping `delay` would carry the wait past its wall-clock bound. */
|
|
285
|
+
function outOfTime(run: PollRun, options: PollingOptions, delay: number): boolean {
|
|
286
|
+
return options.maxWaitMs !== undefined && Date.now() - run.startedAt + delay >= options.maxWaitMs;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Whether this attempt's answer may still be written. A superseded one writes
|
|
291
|
+
* nothing — it may be a hung request finally answering — EXCEPT a terminal
|
|
292
|
+
* status, which is the answer the wait exists for, is idempotent, and would
|
|
293
|
+
* otherwise be dropped because a re-arm landed first.
|
|
294
|
+
*/
|
|
295
|
+
function mayWrite(run: PollRun, mine: number, result: Result<OrderStatus>): boolean {
|
|
296
|
+
return run.attempt === mine || (result.ok && TERMINAL_STATUSES.includes(result.data));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Book the next tick, or end the wait because its clock has run out. */
|
|
300
|
+
function scheduleNext(
|
|
301
|
+
run: PollRun,
|
|
302
|
+
options: PollingOptions,
|
|
303
|
+
sink: PollSink,
|
|
304
|
+
again: () => void,
|
|
305
|
+
): void {
|
|
306
|
+
const delay = pollDelay(run.healthy, run.errors, options);
|
|
307
|
+
if (outOfTime(run, options, delay)) {
|
|
308
|
+
run.stopped = true;
|
|
309
|
+
clearDeadline(run);
|
|
310
|
+
sink.setTimedOut(true);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
clearPending(run);
|
|
314
|
+
run.timer = setTimeout(again, delay);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The handle the hook holds on one running wait. */
|
|
318
|
+
export interface PollLoop {
|
|
319
|
+
/** Reset the clock and the counters, then ask immediately. */
|
|
320
|
+
restart: () => void;
|
|
321
|
+
/** Ask immediately, keeping the clock — the re-arm events' entry point. */
|
|
322
|
+
poke: () => void;
|
|
323
|
+
/** Tear down: nothing further is scheduled and nothing further is written. */
|
|
324
|
+
stop: () => void;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* One self-scheduling wait, with no React in it.
|
|
329
|
+
*
|
|
330
|
+
* A `setTimeout` chain rather than an interval, so requests never overlap; the
|
|
331
|
+
* mutable counters live in one object because the flakiness gate's
|
|
332
|
+
* `no-global-state-mutation` is about exactly this shape, and because the
|
|
333
|
+
* whole loop has to be cancellable from a React cleanup.
|
|
334
|
+
*/
|
|
335
|
+
export function createPollLoop(
|
|
336
|
+
ask: () => Promise<Result<OrderStatus>>,
|
|
337
|
+
options: PollingOptions,
|
|
338
|
+
sink: PollSink,
|
|
339
|
+
): PollLoop {
|
|
340
|
+
const run = newRun();
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
const tick = async (): Promise<void> => {
|
|
344
|
+
if (run.cancelled || run.settled || run.inFlight) return;
|
|
345
|
+
run.inFlight = true;
|
|
346
|
+
run.askedAt = Date.now();
|
|
347
|
+
const mine = ++run.attempt;
|
|
348
|
+
// Bounded, because the wall clock below is only consulted once an ask
|
|
349
|
+
// RETURNS: a request that never returns never reaches it, so the wait that
|
|
350
|
+
// is supposed to end after `maxWaitMs` runs forever showing "we are still
|
|
351
|
+
// trying". Racing a timer turns a hang into an ordinary failed poll, which
|
|
352
|
+
// the backoff and the re-arm already know how to handle.
|
|
353
|
+
const result = await askOnce(ask, run, mine, options);
|
|
354
|
+
if (!mayWrite(run, mine, result)) return;
|
|
355
|
+
if (run.attempt === mine) run.inFlight = false;
|
|
356
|
+
if (run.cancelled || run.settled) return;
|
|
357
|
+
if (!absorb(run, result, sink)) {
|
|
358
|
+
clearDeadline(run);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (run.attempt !== mine) return;
|
|
362
|
+
scheduleNext(run, options, sink, () => void tick());
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
restart: (): void => {
|
|
367
|
+
if (run.cancelled || run.settled) return;
|
|
368
|
+
clearPending(run);
|
|
369
|
+
// Same reasoning as `poke`, and this one is the buyer pressing a button:
|
|
370
|
+
// "Verificar de novo" that cleared the panel and sent nothing — because
|
|
371
|
+
// an ask was still notionally in flight — is the exact complaint.
|
|
372
|
+
run.attempt += 1;
|
|
373
|
+
run.inFlight = false;
|
|
374
|
+
run.stopped = false;
|
|
375
|
+
run.errors = 0;
|
|
376
|
+
run.healthy = 0;
|
|
377
|
+
run.startedAt = Date.now();
|
|
378
|
+
armDeadline(run, options, sink);
|
|
379
|
+
sink.setTimedOut(false);
|
|
380
|
+
sink.setError(null);
|
|
381
|
+
void tick();
|
|
382
|
+
},
|
|
383
|
+
poke: (): void => {
|
|
384
|
+
if (run.cancelled || run.stopped) return;
|
|
385
|
+
if (Date.now() - run.askedAt < REARM_QUIET_MS) return;
|
|
386
|
+
// Deliberately NOT gated on `inFlight`: the shopper who just came back
|
|
387
|
+
// from their bank app is exactly the case where the previous ask is a
|
|
388
|
+
// socket that died while the screen was hidden. Abandon it and ask now.
|
|
389
|
+
run.attempt += 1;
|
|
390
|
+
run.inFlight = false;
|
|
391
|
+
clearPending(run);
|
|
392
|
+
void tick();
|
|
393
|
+
},
|
|
394
|
+
stop: (): void => {
|
|
395
|
+
run.cancelled = true;
|
|
396
|
+
clearPending(run);
|
|
397
|
+
clearDeadline(run);
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
@@ -1,45 +1,9 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
import { useCheckoutClientApi } from "./client-context";
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
interface PollingOptions {
|
|
8
|
-
/** Delay between successful polls (ms). */
|
|
9
|
-
intervalMs?: number;
|
|
10
|
-
/** Poll only while `true` (e.g. after a card charge is submitted). */
|
|
11
|
-
enabled?: boolean;
|
|
12
|
-
/**
|
|
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.
|
|
24
|
-
*/
|
|
25
|
-
maxWaitMs?: number;
|
|
26
|
-
/**
|
|
27
|
-
* Opt-in BACKOFF: after this many healthy polls, keep asking at
|
|
28
|
-
* {@link slowIntervalMs} instead of {@link intervalMs}.
|
|
29
|
-
*
|
|
30
|
-
* A single interval cannot serve a long wait, because the two things it
|
|
31
|
-
* decides pull opposite ways. It is how fast a buyer WHO PAID learns that
|
|
32
|
-
* they did — every poll is a provider round trip, and the answer lands within
|
|
33
|
-
* seconds of the webhook — and it is also what an abandoned checkout costs
|
|
34
|
-
* for the rest of the window. Tuning one picks the other's loser: a slow
|
|
35
|
-
* interval taxes the common case (the person on this screen almost always
|
|
36
|
-
* paid) to subsidise the rare one.
|
|
37
|
-
*
|
|
38
|
-
* Splitting them costs neither. Both must be set for backoff to apply.
|
|
39
|
-
*/
|
|
40
|
-
slowAfterPolls?: number;
|
|
41
|
-
slowIntervalMs?: number;
|
|
42
|
-
}
|
|
4
|
+
import { useCheckoutCopy } from "./copy-context";
|
|
5
|
+
import { createPollLoop, type PollLoop, type PollingOptions } from "./poll-loop";
|
|
6
|
+
import type { OrderStatus } from "./types";
|
|
43
7
|
|
|
44
8
|
/** What a consumer reads off the wait, and the one action it can take. */
|
|
45
9
|
interface PaymentPollingState {
|
|
@@ -60,179 +24,6 @@ interface PaymentPollingState {
|
|
|
60
24
|
checkAgain: () => void;
|
|
61
25
|
}
|
|
62
26
|
|
|
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;
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* How long before the next ask, given how many healthy polls have happened.
|
|
85
|
-
*
|
|
86
|
-
* A pure function of the options, so it lives out here rather than inside the
|
|
87
|
-
* effect — the hook is at its size gate, and a scheduling RULE is easier to
|
|
88
|
-
* read (and to test) stated once than threaded through a closure.
|
|
89
|
-
*
|
|
90
|
-
* Reads the count AFTER the poll just made, so the slow phase begins on the
|
|
91
|
-
* poll FOLLOWING the threshold rather than one early: a wait described as "N
|
|
92
|
-
* fast polls" has to actually make N of them.
|
|
93
|
-
*/
|
|
94
|
-
function healthyDelay(healthy: number, options: PollingOptions): number {
|
|
95
|
-
const { intervalMs = 2500, slowAfterPolls, slowIntervalMs } = options;
|
|
96
|
-
const backingOff =
|
|
97
|
-
slowAfterPolls !== undefined && slowIntervalMs !== undefined && healthy >= slowAfterPolls;
|
|
98
|
-
return backingOff ? slowIntervalMs : intervalMs;
|
|
99
|
-
}
|
|
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
27
|
|
|
237
28
|
/**
|
|
238
29
|
* Ask again the moment the shopper could plausibly have an answer for us.
|
|
@@ -283,6 +74,7 @@ export function usePaymentPolling(
|
|
|
283
74
|
maxWaitMs,
|
|
284
75
|
slowAfterPolls,
|
|
285
76
|
slowIntervalMs,
|
|
77
|
+
askTimeoutMs,
|
|
286
78
|
}: PollingOptions = {},
|
|
287
79
|
): PaymentPollingState {
|
|
288
80
|
const [status, setStatus] = useState<OrderStatus | null>(null);
|
|
@@ -292,6 +84,11 @@ export function usePaymentPolling(
|
|
|
292
84
|
// belongs in the deps below rather than being read out of a ref: a checkout
|
|
293
85
|
// re-pointed at another mount must re-poll against THAT one.
|
|
294
86
|
const client = useCheckoutClientApi();
|
|
87
|
+
// The buyer's own sentence for "we could not reach the server", reused for an
|
|
88
|
+
// ask abandoned for hanging. `Result.error` is rendered verbatim by the PIX,
|
|
89
|
+
// card and wallet panels, so without this a hang printed the English token
|
|
90
|
+
// `timeout` into a Portuguese screen.
|
|
91
|
+
const transportCopy = useCheckoutCopy().screens.transport;
|
|
295
92
|
// The live wait, so the returned action stays stable across renders while
|
|
296
93
|
// still reaching whichever loop the effect currently owns.
|
|
297
94
|
const loop = useRef<PollLoop | null>(null);
|
|
@@ -301,7 +98,14 @@ export function usePaymentPolling(
|
|
|
301
98
|
|
|
302
99
|
const running = createPollLoop(
|
|
303
100
|
() => client.getStatus(orderId),
|
|
304
|
-
{
|
|
101
|
+
{
|
|
102
|
+
intervalMs,
|
|
103
|
+
maxWaitMs,
|
|
104
|
+
slowAfterPolls,
|
|
105
|
+
slowIntervalMs,
|
|
106
|
+
askTimeoutMs,
|
|
107
|
+
askTimeoutError: transportCopy.offline,
|
|
108
|
+
},
|
|
305
109
|
{ setStatus, setError, setTimedOut },
|
|
306
110
|
);
|
|
307
111
|
loop.current = running;
|
|
@@ -313,7 +117,17 @@ export function usePaymentPolling(
|
|
|
313
117
|
running.stop();
|
|
314
118
|
loop.current = null;
|
|
315
119
|
};
|
|
316
|
-
|
|
120
|
+
}, [
|
|
121
|
+
orderId,
|
|
122
|
+
intervalMs,
|
|
123
|
+
enabled,
|
|
124
|
+
maxWaitMs,
|
|
125
|
+
slowAfterPolls,
|
|
126
|
+
slowIntervalMs,
|
|
127
|
+
askTimeoutMs,
|
|
128
|
+
client,
|
|
129
|
+
transportCopy,
|
|
130
|
+
]);
|
|
317
131
|
|
|
318
132
|
const checkAgain = useCallback(() => {
|
|
319
133
|
loop.current?.restart();
|
|
@@ -321,3 +135,4 @@ export function usePaymentPolling(
|
|
|
321
135
|
|
|
322
136
|
return { status, error, timedOut, checkAgain };
|
|
323
137
|
}
|
|
138
|
+
|