@12-apps/payments-frontend 3.24.0 → 3.25.0

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.24.0",
3
+ "version": "3.25.0",
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.",
@@ -3,9 +3,7 @@ import { useCallback, useEffect, useState, type Dispatch, type SetStateAction }
3
3
  import { buyerGateError } from "./buyer-gate";
4
4
  import type { ConfirmationWait } from "./confirmation-wait";
5
5
  import type { CheckoutDecline } from "./decline";
6
- import { forgetHostedOrder, rememberHostedOrder } from "./hosted-return";
7
- import { parkedBasket, type CheckoutBasketIdentity } from "./basket";
8
- import type { CheckoutNavigate } from "./navigate-context";
6
+ import { forgetHostedOrder } from "./hosted-return";
9
7
  import type { CheckoutScreensCopy } from "./screens-copy";
10
8
  import type {
11
9
  BuyerContact,
@@ -13,8 +11,6 @@ import type {
13
11
  BuyerInfo,
14
12
  CheckoutCustomerField,
15
13
  CheckoutOrder,
16
- CreateOrderRequest,
17
- CreateOrderResult,
18
14
  OrderStatus,
19
15
  PaymentMethod,
20
16
  } from "./types";
@@ -282,106 +278,3 @@ export function useRetryAction(input: {
282
278
  clearError, decline, order, setDecline, setFinalStatus, setFreshInstrument, setOrder, setStep,
283
279
  ]);
284
280
  }
285
-
286
- /**
287
- * Hand the buyer to a redirect provider's own page, if that is where this
288
- * charge settles (FUT-556).
289
- *
290
- * Called BEFORE the order is stored: storing it first would render the PIX or
291
- * card step for a provider that returned neither, which is the dead end this
292
- * fixes.
293
- *
294
- * A full navigation rather than the host's router, because the destination is
295
- * another origin. The return trip comes back to this same checkout route
296
- * carrying `transaction_nsu` + `slug`, which the status poll already reads.
297
- *
298
- * @returns true when the buyer is on their way and the caller must stop.
299
- */
300
- function handOverToProvider(
301
- order: CheckoutOrder,
302
- navigate: CheckoutNavigate,
303
- tenantSlug?: string,
304
- basket?: CheckoutBasketIdentity,
305
- ): boolean {
306
- if (!order.hostedCheckoutUrl) return false;
307
- // PARK FIRST, navigate second. The order is the only thing the return trip
308
- // has to rehydrate from, and the navigation may tear this SPA down before
309
- // any later write lands.
310
- //
311
- // The STORE goes with it: one tab holds one slot, and on a multi-tenant
312
- // storefront every store shares an origin. Without the slug, abandoning this
313
- // hand-off and opening another store's checkout resumed THIS order there.
314
- //
315
- // So does the BASKET (FUT-1213): a hand-off nobody completed must not resume
316
- // itself over the shopper's next basket, and the only way to tell the two
317
- // apart later is to record which basket this one was raised from.
318
- rememberHostedOrder(order, {
319
- tenantSlug,
320
- basket: parkedBasket(basket),
321
- handoff: true,
322
- });
323
- navigate(order.hostedCheckoutUrl);
324
- return true;
325
- }
326
-
327
- /**
328
- * Raise the order for a chosen method, and decide what happens to it.
329
- *
330
- * Three outcomes, in order: a refusal the step renders, a HAND-OFF that leaves
331
- * this page for the provider's own, and an order raised here.
332
- */
333
- export function useStartPayment(input: {
334
- buyer: BuyerInfo;
335
- saveProfile: boolean;
336
- createOrder: (request: CreateOrderRequest) => Promise<CreateOrderResult>;
337
- navigate: CheckoutNavigate;
338
- tenantSlug: string | undefined;
339
- basket: CheckoutBasketIdentity | undefined;
340
- failure: { clear: () => void; fail: (next: { message: string; field?: BuyerField | null; code?: string }) => void };
341
- setCreating: Dispatch<SetStateAction<boolean>>;
342
- setDecline: Dispatch<SetStateAction<CheckoutDecline | null>>;
343
- setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>;
344
- setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>;
345
- }): (chosen: PaymentMethod, override?: BuyerInfo) => Promise<void> {
346
- const { buyer, saveProfile, createOrder, navigate, tenantSlug, basket, failure } = input;
347
- const { setCreating, setDecline, setOrder, setFinalStatus } = input;
348
- return useCallback(
349
- async (chosen: PaymentMethod, override?: BuyerInfo) => {
350
- failure.clear();
351
- setDecline(null);
352
- // THE CHARGE BEING REPLACED IS DROPPED FIRST (FUT-1170), before the raise
353
- // rather than after it. Raising a payment means whatever was on screen is
354
- // no longer the one being paid, and a provider round trip is long enough
355
- // for the difference to matter: "Gerar novo código" left the expired
356
- // charge mounted, so its own view polled it, got the terminal EXPIRED it
357
- // was always going to get, and bounced the flow to the confirmation
358
- // screen — where the new charge then landed with nothing polling it.
359
- //
360
- // A no-op on every other caller (the auto-raise, the alternate e-mail and
361
- // the retry all run with no order held), which is the point: the clear
362
- // belongs to what raising a payment MEANS, not to the one path that
363
- // noticed.
364
- setOrder(null);
365
- setFinalStatus(null);
366
- setCreating(true);
367
- const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
368
- setCreating(false);
369
- if (!result.ok) {
370
- failure.fail(result.error);
371
- return;
372
- }
373
- if (handOverToProvider(result.data, navigate, tenantSlug, basket)) return;
374
- // PARKED EVEN THOUGH NOBODY IS LEAVING (FUT-1140). A low-memory phone
375
- // discards this tab while the shopper is in their bank app, and the SPA
376
- // that comes back has never heard of the order it raised — so the buyer
377
- // meets an empty cart and a retry button instead of the confirmation for
378
- // the payment they just made.
379
- rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
380
- setOrder(result.data);
381
- },
382
- [
383
- buyer, saveProfile, createOrder, failure, navigate, tenantSlug, basket,
384
- setCreating, setDecline, setOrder, setFinalStatus,
385
- ],
386
- );
387
- }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The host's realtime channel for THIS buyer, handed to every payment wait
3
+ * below it (FUT-649).
4
+ *
5
+ * Nothing here knows what the channel is. A host that can hear an order move —
6
+ * a signed-in buyer's own topic, say — passes whether that channel is live and
7
+ * a way to be told when it fires; every wait under the provider then asks the
8
+ * moment it fires and slows its own timer while the channel is live. A tree with
9
+ * no provider, or a host that passes `live: false` (a guest, who has no channel
10
+ * of their own), polls exactly as before.
11
+ *
12
+ * The hint carries nothing and is never trusted as an answer: it only makes the
13
+ * wait ask `/status` now. The endpoint stays the truth.
14
+ */
15
+ import { createContext, useCallback, useContext, useEffect, useRef, type JSX, type ReactNode } from "react";
16
+
17
+ import type { PollLoop } from "./poll-loop";
18
+ import { REARM_QUIET_MS } from "./poll-rearm";
19
+
20
+ /** What a host hands the checkout about its own realtime channel. */
21
+ export interface CheckoutLiveSignal {
22
+ /** Whether the channel is connected right now. */
23
+ live: boolean;
24
+ /**
25
+ * Be told when the channel says this buyer's orders moved. Returns the
26
+ * unsubscribe. Should be stable across renders: a new function re-subscribes.
27
+ */
28
+ subscribe: (onHint: () => void) => () => void;
29
+ /** The wait's healthy delay while live (ms). Defaults to {@link DEFAULT_LIVE_INTERVAL_MS}. */
30
+ liveIntervalMs?: number;
31
+ }
32
+
33
+ /**
34
+ * The floor under a lost hint while the channel is live: the same 15 s the PIX
35
+ * wait already backs off to, so a wait whose channel is up never asks faster
36
+ * than the slowest wait it would have become anyway.
37
+ */
38
+ export const DEFAULT_LIVE_INTERVAL_MS = 15_000;
39
+
40
+ const CheckoutLiveContext = createContext<CheckoutLiveSignal | null>(null);
41
+
42
+ /** Hand every payment wait below the host's channel. */
43
+ export function CheckoutLiveProvider({
44
+ signal,
45
+ children,
46
+ }: {
47
+ signal: CheckoutLiveSignal;
48
+ children: ReactNode;
49
+ }): JSX.Element {
50
+ return <CheckoutLiveContext.Provider value={signal}>{children}</CheckoutLiveContext.Provider>;
51
+ }
52
+
53
+ /** What one wait reads off the provider, for `createPollLoop`. */
54
+ interface LiveWait {
55
+ isLive: () => boolean;
56
+ liveIntervalMs: number;
57
+ }
58
+
59
+ type LoopRef = { readonly current: PollLoop | null };
60
+
61
+ /**
62
+ * Ask now — and if the loop declines because it asked a moment ago, ask again
63
+ * once that quiet window is over. The window exists so a flapping network does
64
+ * not stampede `/status`; a hint is different, because the ask it lands just
65
+ * after may have been answered BEFORE the order moved. Dropping it would leave
66
+ * the buyer on the live floor with the news already sent.
67
+ */
68
+ function wake(loop: LoopRef, pending: { timer?: ReturnType<typeof setTimeout> }): void {
69
+ if (loop.current?.poke() !== false) return;
70
+ clearTimeout(pending.timer);
71
+ pending.timer = setTimeout(() => loop.current?.poke(), REARM_QUIET_MS);
72
+ }
73
+
74
+ /**
75
+ * Tie one wait to the host's channel: a hint asks now, and a channel that
76
+ * DROPS asks now too — the timer may be sleeping out the live floor, and the
77
+ * buyer should not wait the rest of it with nobody left to wake them.
78
+ *
79
+ * `loop` is the hook's own ref to its running loop, read when the event lands.
80
+ */
81
+ export function useLiveWait(loop: LoopRef): LiveWait {
82
+ const signal = useContext(CheckoutLiveContext);
83
+ const live = signal?.live === true;
84
+ const liveRef = useRef(live);
85
+ const pending = useRef<{ timer?: ReturnType<typeof setTimeout> }>({});
86
+ const subscribe = signal?.subscribe;
87
+
88
+ useEffect(() => {
89
+ const was = liveRef.current;
90
+ liveRef.current = live;
91
+ if (was && !live) wake(loop, pending.current);
92
+ }, [live, loop]);
93
+
94
+ useEffect(() => {
95
+ if (!subscribe) return undefined;
96
+ const retry = pending.current;
97
+ const unsubscribe = subscribe(() => wake(loop, retry));
98
+ return () => {
99
+ unsubscribe();
100
+ clearTimeout(retry.timer);
101
+ };
102
+ }, [subscribe, loop]);
103
+
104
+ // Stable, so the loop's deps do not churn: the loop reads the flag when it
105
+ // schedules, and a flip must never restart the wait's wall clock.
106
+ const isLive = useCallback(() => liveRef.current, []);
107
+ return { isLive, liveIntervalMs: signal?.liveIntervalMs ?? DEFAULT_LIVE_INTERVAL_MS };
108
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The cadence a payment wait keeps while the host HEARS the order move
3
+ * (FUT-649).
4
+ *
5
+ * The wait asks `/status` every 2.5 s because, on its own, asking is the only
6
+ * way it can learn that a webhook settled the order in another process. A host
7
+ * that holds a realtime channel for this buyer already learns it the moment it
8
+ * happens — `CheckoutLiveProvider` hands that hint in and the wait asks at once.
9
+ * While that channel is live the timer is only the floor under a lost hint, so
10
+ * it slows to {@link LiveCadence.liveIntervalMs}; the moment the channel drops,
11
+ * the ordinary cadence is back on the next tick.
12
+ *
13
+ * Split out of `poll-loop.ts` so the loop stays one question: WHEN to ask. This
14
+ * file answers only how being live changes that answer.
15
+ */
16
+ export interface LiveCadence {
17
+ /**
18
+ * Whether the host's channel is live right now. Read at every scheduling
19
+ * decision rather than once, so a channel that drops mid-wait takes effect
20
+ * at the next tick instead of at the next restart.
21
+ */
22
+ isLive?: () => boolean;
23
+ /**
24
+ * The healthy delay while live (ms). A floor, never a speed-up: a slower
25
+ * cadence already in force (the PIX backoff) is kept.
26
+ */
27
+ liveIntervalMs?: number;
28
+ }
29
+
30
+ /** `delay`, stretched to the live floor while the host's channel is live. */
31
+ export function withLive(delay: number, options: LiveCadence): number {
32
+ if (options.liveIntervalMs === undefined || options.isLive?.() !== true) return delay;
33
+ return Math.max(delay, options.liveIntervalMs);
34
+ }
35
+
36
+ /**
37
+ * Whether the wait must stay open until its wall clock actually strikes.
38
+ *
39
+ * The loop normally ends one sleep EARLY when that sleep would cross
40
+ * `maxWaitMs` — nothing could land in it. While live, something can: the hint.
41
+ * Ending early there would hand a 90 s card wait over at 75 s and then refuse
42
+ * the hint that arrives at 80 s, so the armed deadline ends it instead.
43
+ */
44
+ export function waitsForDeadline(options: LiveCadence): boolean {
45
+ return options.isLive?.() === true;
46
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Result } from "../../result";
2
+ import { waitsForDeadline, withLive, type LiveCadence } from "./poll-live";
2
3
  import { claimRearm } from "./poll-rearm";
3
4
  import { TERMINAL_STATUSES, type OrderStatus } from "./types";
4
5
 
@@ -11,7 +12,7 @@ import { TERMINAL_STATUSES, type OrderStatus } from "./types";
11
12
  * attempt counter that lets a hung request be abandoned rather than waited on.
12
13
  */
13
14
 
14
- export interface PollingOptions {
15
+ export interface PollingOptions extends LiveCadence {
15
16
  /** Delay between successful polls (ms). */
16
17
  intervalMs?: number;
17
18
  /**
@@ -106,7 +107,7 @@ function healthyDelay(healthy: number, options: PollingOptions): number {
106
107
  const { intervalMs = 2500, slowAfterPolls, slowIntervalMs } = options;
107
108
  const backingOff =
108
109
  slowAfterPolls !== undefined && slowIntervalMs !== undefined && healthy >= slowAfterPolls;
109
- return backingOff ? slowIntervalMs : intervalMs;
110
+ return withLive(backingOff ? slowIntervalMs : intervalMs, options);
110
111
  }
111
112
 
112
113
  /**
@@ -281,7 +282,8 @@ async function askOnce(
281
282
 
282
283
  /** Whether sleeping `delay` would carry the wait past its wall-clock bound. */
283
284
  function outOfTime(run: PollRun, options: PollingOptions, delay: number): boolean {
284
- return options.maxWaitMs !== undefined && Date.now() - run.startedAt + delay >= options.maxWaitMs;
285
+ if (options.maxWaitMs === undefined || waitsForDeadline(options)) return false;
286
+ return Date.now() - run.startedAt + delay >= options.maxWaitMs;
285
287
  }
286
288
 
287
289
  /**
@@ -316,8 +318,8 @@ function scheduleNext(
316
318
  export interface PollLoop {
317
319
  /** Reset the clock and the counters, then ask immediately. */
318
320
  restart: () => void;
319
- /** Ask immediately, keeping the clock — the re-arm events' entry point. */
320
- poke: () => void;
321
+ /** Ask immediately, keeping the clock — the re-arm events' entry point. Whether it asked. */
322
+ poke: () => boolean;
321
323
  /** Tear down: nothing further is scheduled and nothing further is written. */
322
324
  stop: () => void;
323
325
  }
@@ -336,8 +338,6 @@ export function createPollLoop(
336
338
  sink: PollSink,
337
339
  ): PollLoop {
338
340
  const run = newRun();
339
-
340
-
341
341
  const tick = async (): Promise<void> => {
342
342
  if (run.cancelled || run.settled || run.inFlight) return;
343
343
  run.inFlight = true;
@@ -364,9 +364,8 @@ export function createPollLoop(
364
364
  restart: (): void => {
365
365
  if (run.cancelled || run.settled) return;
366
366
  clearPending(run);
367
- // Same reasoning as `poke`, and this one is the buyer pressing a button:
368
- // "Verificar de novo" that cleared the panel and sent nothing — because
369
- // an ask was still notionally in flight — is the exact complaint.
367
+ // Same reasoning as `poke`, and this one is the buyer pressing a button: "Verificar
368
+ // de novo" that cleared the panel and sent nothing (an ask was in flight) is the complaint.
370
369
  run.attempt += 1;
371
370
  run.inFlight = false;
372
371
  run.stopped = false;
@@ -379,17 +378,18 @@ export function createPollLoop(
379
378
  sink.setError(null);
380
379
  void tick();
381
380
  },
382
- poke: (): void => {
383
- if (run.cancelled || run.stopped) return;
381
+ poke: (): boolean => {
382
+ if (run.cancelled || run.stopped) return false;
384
383
  // Deliberately NOT gated on `inFlight`: the shopper who just came back
385
384
  // from their bank app is exactly the case where the previous ask is a
386
385
  // socket that died while the screen was hidden. Abandon it and ask now —
387
386
  // but `claimRearm` bounds how many live asks that may abandon in a row.
388
- if (!claimRearm(run)) return;
387
+ if (!claimRearm(run)) return false;
389
388
  run.attempt += 1;
390
389
  run.inFlight = false;
391
390
  clearPending(run);
392
391
  void tick();
392
+ return true;
393
393
  },
394
394
  stop: (): void => {
395
395
  run.cancelled = true;
@@ -28,7 +28,7 @@ interface RearmState {
28
28
  * per trip. This collapses a burst into one request without delaying it: the
29
29
  * first re-arm of a burst polls immediately, the rest fall inside the gap.
30
30
  */
31
- const REARM_QUIET_MS = 1_000;
31
+ export const REARM_QUIET_MS = 1_000;
32
32
 
33
33
  /**
34
34
  * How many live asks a re-arm may abandon before it has to let one finish.
@@ -0,0 +1,151 @@
1
+ import { useCallback, type Dispatch, type SetStateAction } from "react";
2
+
3
+ import { parkedBasket, type CheckoutBasketIdentity } from "./basket";
4
+ import type { Step } from "./checkout-actions";
5
+ import type { CheckoutDecline } from "./decline";
6
+ import { rememberHostedOrder } from "./hosted-return";
7
+ import type { CheckoutNavigate } from "./navigate-context";
8
+ import type {
9
+ BuyerField,
10
+ BuyerInfo,
11
+ CheckoutOrder,
12
+ CreateOrderRequest,
13
+ CreateOrderResult,
14
+ OrderStatus,
15
+ PaymentMethod,
16
+ } from "./types";
17
+
18
+ /**
19
+ * RAISING the order, and what becomes of it.
20
+ *
21
+ * Split out of `./checkout-actions.ts` when that file reached its 400-line gate
22
+ * — the same seam it was itself split along. What is here is the one moment the
23
+ * flow asks the host for an order and then has to decide, from the answer
24
+ * alone, which of four things is true: it was refused, it is already finished,
25
+ * it is payable somewhere else, or it is payable here.
26
+ */
27
+
28
+ /**
29
+ * Hand the buyer to a redirect provider's own page, if that is where this
30
+ * charge settles (FUT-556).
31
+ *
32
+ * Called BEFORE the order is stored: storing it first would render the PIX or
33
+ * card step for a provider that returned neither, which is the dead end this
34
+ * fixes.
35
+ *
36
+ * A full navigation rather than the host's router, because the destination is
37
+ * another origin. The return trip comes back to this same checkout route
38
+ * carrying `transaction_nsu` + `slug`, which the status poll already reads.
39
+ *
40
+ * @returns true when the buyer is on their way and the caller must stop.
41
+ */
42
+ function handOverToProvider(
43
+ order: CheckoutOrder,
44
+ navigate: CheckoutNavigate,
45
+ tenantSlug?: string,
46
+ basket?: CheckoutBasketIdentity,
47
+ ): boolean {
48
+ if (!order.hostedCheckoutUrl) return false;
49
+ // PARK FIRST, navigate second. The order is the only thing the return trip
50
+ // has to rehydrate from, and the navigation may tear this SPA down before
51
+ // any later write lands.
52
+ //
53
+ // The STORE goes with it: one tab holds one slot, and on a multi-tenant
54
+ // storefront every store shares an origin. Without the slug, abandoning this
55
+ // hand-off and opening another store's checkout resumed THIS order there.
56
+ //
57
+ // So does the BASKET (FUT-1213): a hand-off nobody completed must not resume
58
+ // itself over the shopper's next basket, and the only way to tell the two
59
+ // apart later is to record which basket this one was raised from.
60
+ rememberHostedOrder(order, {
61
+ tenantSlug,
62
+ basket: parkedBasket(basket),
63
+ handoff: true,
64
+ });
65
+ navigate(order.hostedCheckoutUrl);
66
+ return true;
67
+ }
68
+
69
+ /**
70
+ * Raise the order for a chosen method, and decide what happens to it.
71
+ *
72
+ * FOUR outcomes, in order: a refusal the step renders, an order that came back
73
+ * ALREADY RESOLVED, a HAND-OFF to the provider's own page, and an order raised
74
+ * here to be paid on this one.
75
+ *
76
+ * The resolved case is first because a raise does not always leave something to
77
+ * pay: a host whose buyer spends a stored balance settles a fully covered
78
+ * pedido server-side and answers PAID with no payable at all. That used to fall
79
+ * through and merely `setOrder`, leaving the flow on Pagamento holding a paid
80
+ * pedido — invisible on the PIX/card screen, which polls and resolves itself,
81
+ * and a dead end on the hand-off screen, which polls nothing and so offered a
82
+ * provider a charge that no longer existed. Reading `status` rather than the
83
+ * absence of a payable is what tells that apart from a failed raise.
84
+ */
85
+ export function useStartPayment(input: {
86
+ buyer: BuyerInfo;
87
+ saveProfile: boolean;
88
+ createOrder: (request: CreateOrderRequest) => Promise<CreateOrderResult>;
89
+ navigate: CheckoutNavigate;
90
+ tenantSlug: string | undefined;
91
+ basket: CheckoutBasketIdentity | undefined;
92
+ failure: { clear: () => void; fail: (next: { message: string; field?: BuyerField | null; code?: string }) => void };
93
+ setCreating: Dispatch<SetStateAction<boolean>>;
94
+ setDecline: Dispatch<SetStateAction<CheckoutDecline | null>>;
95
+ setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>;
96
+ setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>;
97
+ /** Moves the flow to Confirmação for an order that needs no payment. */
98
+ setStep: Dispatch<SetStateAction<Step>>;
99
+ }): (chosen: PaymentMethod, override?: BuyerInfo) => Promise<void> {
100
+ const { buyer, saveProfile, createOrder, navigate, tenantSlug, basket, failure } = input;
101
+ const { setCreating, setDecline, setOrder, setFinalStatus, setStep } = input;
102
+ return useCallback(
103
+ async (chosen: PaymentMethod, override?: BuyerInfo) => {
104
+ failure.clear();
105
+ setDecline(null);
106
+ // THE CHARGE BEING REPLACED IS DROPPED FIRST (FUT-1170), before the raise
107
+ // rather than after it. Raising a payment means whatever was on screen is
108
+ // no longer the one being paid, and a provider round trip is long enough
109
+ // for the difference to matter: "Gerar novo código" left the expired
110
+ // charge mounted, so its own view polled it, got the terminal EXPIRED it
111
+ // was always going to get, and bounced the flow to the confirmation
112
+ // screen — where the new charge then landed with nothing polling it.
113
+ //
114
+ // A no-op on every other caller (the auto-raise, the alternate e-mail and
115
+ // the retry all run with no order held), which is the point: the clear
116
+ // belongs to what raising a payment MEANS, not to the one path that
117
+ // noticed.
118
+ setOrder(null);
119
+ setFinalStatus(null);
120
+ setCreating(true);
121
+ const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
122
+ setCreating(false);
123
+ if (!result.ok) {
124
+ failure.fail(result.error);
125
+ return;
126
+ }
127
+ // NOTHING LEFT TO PAY — see the docblock. Parked first for the same
128
+ // reason every other raised order is: the confirmation this is about to
129
+ // show has to survive a tab the phone discards on the way to it.
130
+ if (result.data.status !== "AWAITING_PAYMENT") {
131
+ rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
132
+ setOrder(result.data);
133
+ setFinalStatus(result.data.status);
134
+ setStep("status");
135
+ return;
136
+ }
137
+ if (handOverToProvider(result.data, navigate, tenantSlug, basket)) return;
138
+ // PARKED EVEN THOUGH NOBODY IS LEAVING (FUT-1140). A low-memory phone
139
+ // discards this tab while the shopper is in their bank app, and the SPA
140
+ // that comes back has never heard of the order it raised — so the buyer
141
+ // meets an empty cart and a retry button instead of the confirmation for
142
+ // the payment they just made.
143
+ rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
144
+ setOrder(result.data);
145
+ },
146
+ [
147
+ buyer, saveProfile, createOrder, failure, navigate, tenantSlug, basket,
148
+ setCreating, setDecline, setOrder, setFinalStatus, setStep,
149
+ ],
150
+ );
151
+ }
@@ -9,9 +9,9 @@ import {
9
9
  useResumedCheckout,
10
10
  useRetryAction,
11
11
  useSettledPort,
12
- useStartPayment,
13
12
  type Step,
14
13
  } from "./checkout-actions";
14
+ import { useStartPayment } from "./start-payment";
15
15
  import { useConfirmationWait } from "./confirmation-wait";
16
16
  import { useCheckoutCopy } from "./copy-context";
17
17
 
@@ -139,7 +139,7 @@ export function useCheckoutController(
139
139
  });
140
140
  const startPayment = useStartPayment({
141
141
  buyer, saveProfile, createOrder, navigate, tenantSlug, basket, failure,
142
- setCreating, setDecline, setOrder, setFinalStatus,
142
+ setCreating, setDecline, setOrder, setFinalStatus, setStep,
143
143
  });
144
144
  const { payWithEmail, handleResolved } = useResolutionActions({
145
145
  buyer, method, startPayment, setBuyerState, setDecline, setFinalStatus, setStep,
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateA
3
3
  import { useCheckoutClientApi } from "./client-context";
4
4
  import { useCheckoutCopy } from "./copy-context";
5
5
  import { createPollLoop, type PollLoop, type PollingOptions, type PollSink } from "./poll-loop";
6
+ import { useLiveWait } from "./live-context";
6
7
  import type { OrderStatus } from "./types";
7
8
 
8
9
  /** What a consumer reads off the wait, and the one action it can take. */
@@ -134,6 +135,7 @@ export function usePaymentPolling(
134
135
  // The live wait, so the returned action stays stable across renders while
135
136
  // still reaching whichever loop the effect currently owns.
136
137
  const loop = useRef<PollLoop | null>(null);
138
+ const { isLive, liveIntervalMs } = useLiveWait(loop); // FUT-649
137
139
 
138
140
  useEffect(() => {
139
141
  if (!orderId || !enabled) return undefined;
@@ -147,6 +149,7 @@ export function usePaymentPolling(
147
149
  slowIntervalMs,
148
150
  askTimeoutMs,
149
151
  askTimeoutError: transportCopy.offline,
152
+ ...{ isLive, liveIntervalMs },
150
153
  },
151
154
  sinkFor(orderId, setAnswer),
152
155
  );
@@ -169,6 +172,7 @@ export function usePaymentPolling(
169
172
  askTimeoutMs,
170
173
  client,
171
174
  transportCopy,
175
+ isLive, liveIntervalMs,
172
176
  ]);
173
177
 
174
178
  const checkAgain = useCallback(() => {
package/src/index.ts CHANGED
@@ -131,6 +131,13 @@ export type {
131
131
  } from './components/checkout/screens-copy';
132
132
  export { PT_BR_CHECKOUT_SCREENS_COPY } from './components/checkout/screens-pt-BR';
133
133
  export { fetchCheckoutConfig } from './components/checkout/client';
134
+ // The host's realtime channel for this buyer (FUT-649): a hint asks `/status`
135
+ // now, and every wait slows to a floor while the channel is live.
136
+ export {
137
+ CheckoutLiveProvider,
138
+ DEFAULT_LIVE_INTERVAL_MS,
139
+ type CheckoutLiveSignal,
140
+ } from './components/checkout/live-context';
134
141
  /**
135
142
  * The `sessionStorage` key the hosted-checkout return leg parks the raised
136
143
  * order under. Public because it is already observable — a spec asserting the