@12-apps/payments-frontend 3.21.3 → 3.22.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.
Files changed (33) hide show
  1. package/package.json +2 -2
  2. package/src/components/checkout/basket.ts +85 -0
  3. package/src/components/checkout/card-outcome.ts +81 -0
  4. package/src/components/checkout/card-view.tsx +62 -22
  5. package/src/components/checkout/checkout-actions.ts +341 -0
  6. package/src/components/checkout/checkout-flow.tsx +112 -18
  7. package/src/components/checkout/checkout-steps.tsx +149 -174
  8. package/src/components/checkout/checkout-totals.tsx +51 -0
  9. package/src/components/checkout/client-context.tsx +3 -0
  10. package/src/components/checkout/dados-step.tsx +141 -0
  11. package/src/components/checkout/decline.ts +48 -0
  12. package/src/components/checkout/en-US.ts +33 -0
  13. package/src/components/checkout/hosted-return.ts +190 -206
  14. package/src/components/checkout/hosted-store.ts +269 -0
  15. package/src/components/checkout/payment-status-parts.tsx +311 -0
  16. package/src/components/checkout/payment-status.tsx +69 -264
  17. package/src/components/checkout/poll-loop.ts +10 -12
  18. package/src/components/checkout/poll-rearm.ts +86 -0
  19. package/src/components/checkout/providers/types.ts +20 -3
  20. package/src/components/checkout/pt-BR.ts +33 -0
  21. package/src/components/checkout/screens-copy.ts +14 -0
  22. package/src/components/checkout/screens-en-US.ts +1 -0
  23. package/src/components/checkout/screens-pt-BR.ts +3 -0
  24. package/src/components/checkout/transport.ts +21 -1
  25. package/src/components/checkout/types.ts +24 -0
  26. package/src/components/checkout/use-card-checkout.ts +31 -31
  27. package/src/components/checkout/use-checkout-controller.ts +51 -271
  28. package/src/components/checkout/use-hosted-resume.ts +326 -0
  29. package/src/components/checkout/view-copy.ts +32 -0
  30. package/src/components/checkout/wallet-pane.tsx +3 -0
  31. package/src/flows/create-payment-flows.tsx +7 -0
  32. package/src/flows/screens-hosted.tsx +19 -2
  33. package/src/index.ts +22 -0
@@ -96,10 +96,20 @@ export interface CheckoutTransport {
96
96
  export type CheckoutTransportBinding = Omit<CheckoutTransport, "copy"> &
97
97
  Partial<Pick<CheckoutTransport, "copy">>;
98
98
 
99
- /** The eight calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
99
+ /** The nine calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
100
100
  export interface CheckoutClient {
101
101
  getConfig(tenantSlug: string): Promise<Result<CheckoutProviderConfig>>;
102
102
  getStatus(ref: string): Promise<Result<OrderStatus>>;
103
+ /**
104
+ * `POST /release` (FUT-1146): the buyer says they did not pay, and the
105
+ * charge they were sent away for has no terminal state of its own.
106
+ *
107
+ * Answers the payable's status AFTER the server has re-asked the provider,
108
+ * so a payment that actually succeeded comes back `PAID` and nothing is
109
+ * released — the race against a late webhook resolves in the shopper's
110
+ * favour rather than against it.
111
+ */
112
+ releaseCheckout(input: { orderId: string }): Promise<Result<OrderStatus>>;
103
113
  charge(input: ChargeCardInput): Promise<Result<ChargeOutcome>>;
104
114
  /** A wallet instrument against the same `/charge` route (FUT-471/472). */
105
115
  chargeWallet(input: ChargeWalletInput): Promise<Result<ChargeOutcome>>;
@@ -266,6 +276,16 @@ export function createCheckoutClient(transport: CheckoutTransport): CheckoutClie
266
276
  { method: "GET" },
267
277
  ),
268
278
 
279
+ releaseCheckout: (input) =>
280
+ // The settlement hints ride in the QUERY, exactly as the poll's do: they
281
+ // are the only way a hosted provider can be asked anything at all, so a
282
+ // release must carry them or the server decides "not paid" from a
283
+ // question it was never able to put.
284
+ call<OrderStatus>(`/release?${new URLSearchParams(returnedSettlement()).toString()}`, {
285
+ method: "POST",
286
+ body: JSON.stringify({ orderId: input.orderId }),
287
+ }),
288
+
269
289
  charge: (input) =>
270
290
  call<ChargeOutcome>("/charge", { method: "POST", body: flatChargeBody(input) }),
271
291
 
@@ -9,6 +9,8 @@
9
9
  * the ports declared on `CheckoutFlowProps`.
10
10
  */
11
11
 
12
+ import type { CheckoutDecline, CheckoutDeclineReason } from "./decline";
13
+
12
14
  /** Payment methods offered at checkout. Mirrors `Payment.method` (FUT-42). */
13
15
  export type PaymentMethod = "PIX" | "CARD";
14
16
 
@@ -34,6 +36,16 @@ export type OrderStatus = "AWAITING_PAYMENT" | "PAID" | "FAILED" | "EXPIRED";
34
36
  /** Terminal states — polling stops once the order reaches one of these. */
35
37
  export const TERMINAL_STATUSES: readonly OrderStatus[] = ["PAID", "FAILED", "EXPIRED"];
36
38
 
39
+ /**
40
+ * What a payment pane calls once the charge has an answer.
41
+ *
42
+ * The refusal rides along OPTIONALLY (FUT-1145): a pane that has one hands it
43
+ * over so the confirmation screen can say which refusal this was and whether
44
+ * another instrument could work, and every caller that has nothing to add keeps
45
+ * calling this with one argument exactly as before.
46
+ */
47
+ export type OnCheckoutResolved = (status: OrderStatus, decline?: CheckoutDecline | null) => void;
48
+
37
49
  /** Buyer contact captured at checkout. */
38
50
  export interface BuyerInfo {
39
51
  name?: string;
@@ -358,8 +370,20 @@ export interface CheckoutProviderConfig {
358
370
  * present when the provider demands the buyer finish the charge on ITS page —
359
371
  * Stripe's redirect-based 3-D Secure — and the client then hands the buyer
360
372
  * over exactly as it does for a redirect provider's link (FUT-556).
373
+ *
374
+ * `declineReason` / `retriable` ride along on a refusal (FUT-1145). The server
375
+ * has classified every acquirer decline since FUT-340 — 33 PagBank codes with
376
+ * issuer sub-reasons, each carrying the vendor's own retry verdict — and then
377
+ * answered `{ status }` and threw the classification away, so an expired card,
378
+ * a stolen card and "attempts exhausted, do not retry" all reached the buyer as
379
+ * "Pagamento não concluído. Você pode tentar novamente."
380
+ *
381
+ * BOTH OPTIONAL, and the degrade direction is today's behaviour: a server that
382
+ * sends neither renders exactly the generic refusal it always did.
361
383
  */
362
384
  export interface ChargeOutcome {
363
385
  status: OrderStatus;
364
386
  hostedCheckoutUrl?: string;
387
+ declineReason?: CheckoutDeclineReason;
388
+ retriable?: boolean;
365
389
  }
@@ -22,12 +22,12 @@ import {
22
22
  type CardInstruments,
23
23
  type RefreshBrowserKey,
24
24
  } from "./card-instruments";
25
+ import { handOverToChallenge, reportResolved, type ChallengeScope } from "./card-outcome";
25
26
  import { useCheckoutClientApi } from "./client-context";
26
- import { rememberHostedOrder } from "./hosted-return";
27
- import { useCheckoutNavigate, type CheckoutNavigate } from "./navigate-context";
27
+ import { useCheckoutNavigate } from "./navigate-context";
28
28
  import type { CardChainLink } from "./method-capability";
29
29
  import { useOneClickArmed, useOneClickPay } from "./one-click";
30
- import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
30
+ import type { BuyerInfo, CheckoutOrder, OnCheckoutResolved, OrderStatus } from "./types";
31
31
  import { usePaymentPolling } from "./use-payment-polling";
32
32
  import type { CardCopy } from "../../card/copy";
33
33
  import { useCheckoutCopy } from "./copy-context";
@@ -40,7 +40,17 @@ const EMPTY_CARD: CardDetails = { number: "", holder: "", expiry: "", cvv: "" };
40
40
  * actually charge are offered. The slug arrives as an argument (the host's
41
41
  * routing owns it); absent, the list is unscoped, exactly as before FUT-697.
42
42
  */
43
- function useSavedCards(tenantSlug: string | undefined): {
43
+ function useSavedCards(
44
+ tenantSlug: string | undefined,
45
+ /**
46
+ * The buyer is here because a card was REFUSED (FUT-1145), so the saved card
47
+ * this would otherwise preselect is the one that just failed. The list is
48
+ * still offered — another saved card may well work — but nothing is chosen
49
+ * for them, which puts the form in front of a buyer whose only untried
50
+ * instrument is a new one.
51
+ */
52
+ freshInstrument: boolean,
53
+ ): {
44
54
  savedCards: SavedCard[];
45
55
  selection: string;
46
56
  setSelection: (id: string) => void;
@@ -55,12 +65,12 @@ function useSavedCards(tenantSlug: string | undefined): {
55
65
  if (!active) return;
56
66
  setSavedCards(cards);
57
67
  const first = cards[0];
58
- if (first) setSelection(first.id);
68
+ if (first && !freshInstrument) setSelection(first.id);
59
69
  });
60
70
  return () => {
61
71
  active = false;
62
72
  };
63
- }, [tenantSlug, client]);
73
+ }, [tenantSlug, client, freshInstrument]);
64
74
 
65
75
  return { savedCards, selection, setSelection };
66
76
  }
@@ -164,24 +174,6 @@ type CardSubmit = Pick<
164
174
  */
165
175
  const CARD_AWAITING_WAIT_MS = 90_000;
166
176
 
167
- /**
168
- * Hand the buyer to the provider's authentication page (FUT-698) — Stripe's
169
- * redirect-based 3-D Secure. Park the order and navigate, the same trip a
170
- * redirect provider's link takes (FUT-556): the return lands back on this
171
- * checkout route, where the hosted-resume machinery polls the parked order.
172
- */
173
- function handOverToChallenge(
174
- order: CheckoutOrder,
175
- url: string,
176
- navigate: CheckoutNavigate,
177
- ): void {
178
- // PARK FIRST. The navigation may not come back to a live SPA at all, and a
179
- // return trip that finds nothing parked lands the buyer on a blank
180
- // confirmation after they have paid.
181
- rememberHostedOrder(order);
182
- navigate(url);
183
- }
184
-
185
177
  /** The buyer's card is invalid — block the submit and show which field. */
186
178
  function blockedByForm(form: CardFormState): boolean {
187
179
  if (!form.usingNewCard) return false;
@@ -257,10 +249,13 @@ function useCardSubmit(
257
249
  order: CheckoutOrder,
258
250
  buyer: BuyerInfo,
259
251
  providerConfig: CardTokenizationConfig,
260
- onResolved: (status: OrderStatus) => void,
252
+ onResolved: OnCheckoutResolved,
261
253
  pollIntervalMs: number,
262
254
  form: CardFormState,
263
255
  providerChain: readonly CardChainLink[],
256
+ /** WHOSE store and WHICH basket this charge is for — parked with a 3DS
257
+ * hand-off, which is otherwise resumable over any basket anywhere. */
258
+ scope: ChallengeScope,
264
259
  ): CardSubmit {
265
260
  const [submitting, setSubmitting] = useState(false);
266
261
  const [submitted, setSubmitted] = useState(false);
@@ -319,11 +314,11 @@ function useCardSubmit(
319
314
  // 3-D Secure (FUT-698): the buyer must finish on the provider's page.
320
315
  // `submitting` stays true on purpose — the tab is navigating away.
321
316
  if (charged.data.hostedCheckoutUrl) {
322
- return handOverToChallenge(order, charged.data.hostedCheckoutUrl, navigate);
317
+ return handOverToChallenge(order, charged.data.hostedCheckoutUrl, navigate, scope);
323
318
  }
324
- // A business outcome (e.g. declined FAILED) shows the status screen;
325
- // an accepted charge begins polling for the async confirmation.
326
- if (charged.data.status !== "AWAITING_PAYMENT") onResolved(charged.data.status);
319
+ // A business outcome shows the status screen, carrying the refusal the
320
+ // server classified (FUT-1145); an accepted charge begins polling.
321
+ if (charged.data.status !== "AWAITING_PAYMENT") reportResolved(charged.data, onResolved);
327
322
  else setSubmitted(true);
328
323
  };
329
324
 
@@ -338,7 +333,7 @@ export function useCardCheckout(
338
333
  order: CheckoutOrder,
339
334
  buyer: BuyerInfo,
340
335
  providerConfig: CardTokenizationConfig,
341
- onResolved: (status: OrderStatus) => void,
336
+ onResolved: OnCheckoutResolved,
342
337
  pollIntervalMs: number,
343
338
  /** The store whose saved cards may be offered (host routing owns the slug). */
344
339
  tenantSlug?: string,
@@ -347,8 +342,12 @@ export function useCardCheckout(
347
342
  * per entry so a card charge can fail over. Omitted ⇒ the head alone.
348
343
  */
349
344
  providerChain: readonly CardChainLink[] = [],
345
+ /** Do not preselect a saved card — the last one was refused (FUT-1145). */
346
+ freshInstrument = false,
347
+ /** WHOSE store and WHICH basket, for the 3-D Secure hand-off's parked order. */
348
+ scope: ChallengeScope = {},
350
349
  ): CardCheckout {
351
- const { savedCards, selection, setSelection } = useSavedCards(tenantSlug);
350
+ const { savedCards, selection, setSelection } = useSavedCards(tenantSlug, freshInstrument);
352
351
  const [card, setCard] = useState<CardDetails>(EMPTY_CARD);
353
352
  const [fieldErrors, setFieldErrors] = useState<CardFieldErrors>({});
354
353
  const [saveCard, setSaveCard] = useState(false);
@@ -373,6 +372,7 @@ export function useCardCheckout(
373
372
  pollIntervalMs,
374
373
  { card, usingNewCard, selection, saveCard, validate, setFieldErrors },
375
374
  providerChain,
375
+ scope,
376
376
  );
377
377
  // The tap a one-click buyer already made (`./one-click.tsx`). Nothing about
378
378
  // the charge differs — this only presses the button, and only while a SAVED
@@ -1,13 +1,24 @@
1
- import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
2
-
3
- import { buyerGateError } from "./buyer-gate";
1
+ import { useCallback, useMemo, useState } from "react";
2
+
3
+ import type { CheckoutBasketIdentity } from "./basket";
4
+ import {
5
+ resumeSurface,
6
+ useCheckoutNav,
7
+ useCreateFailure,
8
+ useGoToPayment,
9
+ useResumedCheckout,
10
+ useRetryAction,
11
+ useSettledPort,
12
+ useStartPayment,
13
+ type Step,
14
+ } from "./checkout-actions";
4
15
  import { useCheckoutCopy } from "./copy-context";
5
16
 
6
- import { rememberHostedOrder, takeHostedOrder } from "./hosted-return";
7
- import { useCheckoutNavigate, type CheckoutNavigate } from "./navigate-context";
17
+ import { forgetHostedOrder } from "./hosted-return";
18
+ import { useCheckoutNavigate } from "./navigate-context";
19
+ import type { CheckoutDecline } from "./decline";
8
20
  import type {
9
21
  BuyerContact,
10
- BuyerField,
11
22
  BuyerInfo,
12
23
  CheckoutOrder,
13
24
  CreateOrderRequest,
@@ -16,9 +27,7 @@ import type {
16
27
  OrderStatus,
17
28
  PaymentMethod,
18
29
  } from "./types";
19
- import { usePaymentPolling } from "./use-payment-polling";
20
-
21
- type Step = "dados" | "payment" | "status";
30
+ import { useHostedResume } from "./use-hosted-resume";
22
31
 
23
32
  const STEP_ORDER: Step[] = ["dados", "payment", "status"];
24
33
 
@@ -57,233 +66,6 @@ export interface CheckoutHostPorts {
57
66
  onPaid?: () => void;
58
67
  }
59
68
 
60
- /**
61
- * The flow's navigation actions, split out of {@link useCheckoutController} for
62
- * the 80-line per-function gate.
63
- *
64
- * `back` is where "the Dados step was skipped" has to be honoured: going back
65
- * off Pagamento normally lands on Dados, but for a buyer with a CPF on file
66
- * that step is not part of their flow, so the menu is the only honest
67
- * destination — until they open it themselves via `editBuyer` ("alterar" on the
68
- * payer block), after which it IS part of their flow and back returns to it.
69
- */
70
- function useCheckoutNav(
71
- taxIdOnFile: boolean,
72
- goToMenu: () => void,
73
- setStep: Dispatch<SetStateAction<Step>>,
74
- ): { back: () => void; editBuyer: (() => void) | undefined } {
75
- const [dadosOpened, setDadosOpened] = useState(false);
76
- const openDados = useCallback(() => {
77
- setDadosOpened(true);
78
- setStep("dados");
79
- }, [setStep]);
80
- const back = useCallback(() => {
81
- setStep((current) => {
82
- if (current === "payment" && (!taxIdOnFile || dadosOpened)) return "dados";
83
- goToMenu();
84
- return current;
85
- });
86
- }, [dadosOpened, goToMenu, setStep, taxIdOnFile]);
87
- // Undefined unless Dados was skipped — the payer block keys off its presence,
88
- // so the decision lives here rather than being re-derived by every caller.
89
- return { back, editBuyer: taxIdOnFile ? openDados : undefined };
90
- }
91
-
92
- /**
93
- * Where checkout opens: on the outcome for a buyer returning from a hosted
94
- * provider, on Pagamento when their CPF is already on file, else on Dados.
95
- */
96
- function initialStep(resuming: boolean, taxIdOnFile: boolean): Step {
97
- if (resuming) return "status";
98
- return taxIdOnFile ? "payment" : "dados";
99
- }
100
-
101
- /**
102
- * Hand the buyer to a redirect provider's own page, if that is where this
103
- * charge settles (FUT-556).
104
- *
105
- * Called BEFORE the order is stored: storing it first would render the PIX or
106
- * card step for a provider that returned neither, which is the dead end this
107
- * fixes.
108
- *
109
- * A full navigation rather than the host's router, because the destination is
110
- * another origin. The return trip comes back to this same checkout route
111
- * carrying `transaction_nsu` + `slug`, which the status poll already reads.
112
- *
113
- * @returns true when the buyer is on their way and the caller must stop.
114
- */
115
- function handOverToProvider(
116
- order: CheckoutOrder,
117
- navigate: CheckoutNavigate,
118
- tenantSlug?: string,
119
- ): boolean {
120
- if (!order.hostedCheckoutUrl) return false;
121
- // PARK FIRST, navigate second. The order is the only thing the return trip
122
- // has to rehydrate from, and the navigation may tear this SPA down before
123
- // any later write lands.
124
- //
125
- // The STORE goes with it: one tab holds one slot, and on a multi-tenant
126
- // storefront every store shares an origin. Without the slug, abandoning this
127
- // hand-off and opening another store's checkout resumed THIS order there.
128
- rememberHostedOrder(order, tenantSlug);
129
- navigate(order.hostedCheckoutUrl);
130
- return true;
131
- }
132
-
133
- /**
134
- * How long the resumed screen keeps asking, and how often.
135
- *
136
- * TWO RATES, because one rate cannot serve this wait. The interval decides two
137
- * things that pull opposite ways: how fast a buyer WHO PAID is told so, and
138
- * what an abandoned checkout costs for the rest of the window. Every poll is a
139
- * provider round trip, so a slow rate is cheap and leaves a paying buyer
140
- * watching a spinner seconds longer than they need to — and the person on this
141
- * screen has almost always paid. A single number picks one of them to lose;
142
- * this shipped at a flat 5 s and picked the wrong one.
143
- *
144
- * So: 2.5 s for the first two minutes, which is where essentially every real
145
- * webhook lands (it fires within seconds of the payment, and this rate matches
146
- * what card and PIX already use), then 10 s for the remaining thirteen. A
147
- * confirmation is at most 2.5 s late, and an abandoned checkout costs ~126
148
- * polls instead of the 360 a flat 2.5 s would have.
149
- *
150
- * Fifteen minutes because by then a webhook that was ever coming has come. Past
151
- * that the answer will not change while the buyer watches: the scheduled
152
- * reconciliation is what rescues a genuinely late one, and it does that whether
153
- * the tab is open or not.
154
- *
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
163
- * authorises inline and a buyer is holding their phone. This leg is the other
164
- * shape: the buyer has already been off to another site and back, and may
165
- * legitimately still be finishing there.
166
- */
167
- const HOSTED_RESUME_FAST_MS = 2_500;
168
- const HOSTED_RESUME_SLOW_MS = 10_000;
169
- /** Two minutes at the fast rate, before the wait is worth economising on. */
170
- const HOSTED_RESUME_FAST_POLLS = (2 * 60_000) / HOSTED_RESUME_FAST_MS;
171
- /** Thirteen more at the slow one — 15 minutes all told. */
172
- const HOSTED_RESUME_WINDOW_MS = 15 * 60_000;
173
-
174
- /**
175
- * The leg of checkout that resumes after a hosted provider sent the buyer back
176
- * (FUT-556).
177
- *
178
- * The SPA was torn down by the redirect, so the order is rehydrated from the
179
- * parked copy — once, on first render — and polled here rather than in a PIX or
180
- * card view, because a redirect provider produced neither. The webhook is still
181
- * what settles the order; this only tells the buyer that it did.
182
- *
183
- * And it stops telling them eventually. This poll was the one unbounded wait
184
- * left in checkout — card and wallet both cap theirs — so a buyer who came back
185
- * from a payment they never completed got "Confirmando seu pagamento… isso
186
- * costuma levar alguns segundos" and a spinner, truthfully forever. The screen
187
- * had no way to reach a terminal state, because the ORDER has none: expiry is
188
- * PIX-only, and a redirect charge carries no QR window to lapse. `timedOut` is
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.
199
- */
200
- function useHostedResume(tenantSlug?: string): {
201
- order: CheckoutOrder | null;
202
- status: OrderStatus | null;
203
- timedOut: boolean;
204
- error: string | null;
205
- checkAgain: () => void;
206
- } {
207
- const [order] = useState(() => takeHostedOrder(tenantSlug));
208
- const { status, timedOut, error, checkAgain } = usePaymentPolling(order?.orderId ?? null, {
209
- enabled: Boolean(order),
210
- intervalMs: HOSTED_RESUME_FAST_MS,
211
- slowAfterPolls: HOSTED_RESUME_FAST_POLLS,
212
- slowIntervalMs: HOSTED_RESUME_SLOW_MS,
213
- maxWaitMs: HOSTED_RESUME_WINDOW_MS,
214
- });
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
- };
240
- }
241
-
242
- /**
243
- * Fire the host's `onPaid` port once the order settles PAID.
244
- *
245
- * FUT-601 made the SERVER empty the cart inside the confirmation transaction —
246
- * but nothing told the SPA, whose cart provider survives every checkout route
247
- * change and kept counting the items the buyer had just bought. The server was
248
- * right and the screen was stale; this is where the host is told to catch up.
249
- *
250
- * PAID only. A FAILED or EXPIRED order fires nothing — that shopper still has
251
- * a basket to retry with, and the host must not be told otherwise.
252
- */
253
- function usePaidPort(settled: OrderStatus | null, onPaid: (() => void) | undefined): void {
254
- useEffect(() => {
255
- if (settled !== "PAID") return;
256
- onPaid?.();
257
- }, [settled, onPaid]);
258
- }
259
-
260
- /**
261
- * The create-order refusal the steps render: what to say, which field to
262
- * highlight, and the machine CODE that decides how it is presented — an
263
- * unresolved charge is not a failed one, and the Pagamento step must not offer
264
- * it a "Tentar novamente" (FUT-563). One hook so the three always move
265
- * together; they were three `useState`s that could be cleared apart.
266
- */
267
- function useCreateFailure() {
268
- const [message, setMessage] = useState<string | null>(null);
269
- const [field, setField] = useState<BuyerField | null>(null);
270
- const [code, setCode] = useState<string | null>(null);
271
- const clear = useCallback(() => {
272
- setMessage(null);
273
- setField(null);
274
- setCode(null);
275
- }, []);
276
- const fail = useCallback(
277
- (next: { message: string; field?: BuyerField | null; code?: string }) => {
278
- setMessage(next.message);
279
- setField(next.field ?? null);
280
- setCode(next.code ?? null);
281
- },
282
- [],
283
- );
284
- return { message, field, code, clear, fail };
285
- }
286
-
287
69
  /**
288
70
  * All checkout state + handlers, so the checkout flow stays presentational.
289
71
  * - `setMethod` drops any order raised for the previous method (no stale QR/form).
@@ -295,6 +77,8 @@ function useCreateFailure() {
295
77
  * being saved.
296
78
  * - `startPayment` raises the order (PIX → QR, CARD → chargeable); `payWithEmail`
297
79
  * re-raises with a corrected e-mail for the merchant-email rejection.
80
+ * - `retry` re-charges the SAME order when the refusal says another instrument
81
+ * could work (FUT-1145), and raises a fresh one when it does not.
298
82
  *
299
83
  * @param taxIdOnFile The buyer already has a CPF saved (FUT-465) ⇒ the Dados
300
84
  * step has nothing left to ask, so checkout opens on Pagamento and `back`
@@ -304,6 +88,9 @@ function useCreateFailure() {
304
88
  * @param buyerFields What the store's chain declares it needs from the buyer
305
89
  * (FUT-595). Absent ⇒ CPF-required, which is what this gate demanded before
306
90
  * there was a declaration to read — never "ask nothing".
91
+ * @param basket WHICH basket this checkout is for (FUT-1213). Absent ⇒ the
92
+ * pre-1213 behaviour: a parked payment resumes on whatever checkout mounts
93
+ * next. See `./basket.ts` for why it is a signature of the lines.
307
94
  */
308
95
  export function useCheckoutController(
309
96
  ports: CheckoutHostPorts,
@@ -311,71 +98,64 @@ export function useCheckoutController(
311
98
  taxIdOnFile = false,
312
99
  buyerFields: readonly CheckoutCustomerField[] = CPF_ONLY,
313
100
  tenantSlug?: string,
101
+ basket?: CheckoutBasketIdentity,
314
102
  ) {
315
103
  const { createOrder, saveBuyerContact, onExitToMenu, onPaid } = ports;
316
104
  const validation = useCheckoutCopy().screens.validation;
317
105
  const navigate = useCheckoutNavigate();
318
- const resume = useHostedResume(tenantSlug);
319
- const [step, setStep] = useState<Step>(initialStep(Boolean(resume.order), taxIdOnFile));
106
+ const resume = useHostedResume(tenantSlug, basket);
107
+ const [step, setStep] = useState<Step>(taxIdOnFile ? "payment" : "dados");
320
108
  // No method pre-selected: the Pagamento step shows just the picker until the
321
109
  // buyer chooses PIX or card, then that method's order is raised and its UI
322
110
  // revealed. Avoids raising a throwaway PIX charge for a buyer who wants card.
323
111
  const [method, setMethodState] = useState<PaymentMethod | null>(null);
324
112
  const [buyer, setBuyerState] = useState<BuyerInfo>(defaultBuyer ?? {});
325
113
  const [saveProfile, setSaveProfile] = useState(true);
326
- const [order, setOrder] = useState<CheckoutOrder | null>(resume.order);
114
+ const [order, setOrder] = useState<CheckoutOrder | null>(null);
327
115
  const [finalStatus, setFinalStatus] = useState<OrderStatus | null>(null);
116
+ const [decline, setDecline] = useState<CheckoutDecline | null>(null);
117
+ const [freshInstrument, setFreshInstrument] = useState(false);
328
118
  const [creating, setCreating] = useState(false);
329
119
  const failure = useCreateFailure();
120
+ useResumedCheckout(resume, setOrder, setStep, setFinalStatus, setMethodState);
330
121
 
331
122
  const clearError = failure.clear;
332
123
  const setBuyer = useCallback((next: BuyerInfo) => { setBuyerState(next); clearError(); }, [clearError]);
333
124
  const { back, editBuyer } = useCheckoutNav(taxIdOnFile, onExitToMenu, setStep);
334
125
  const setMethod = useCallback((next: PaymentMethod) => {
335
- setMethodState((prev) => { if (prev !== next) { setOrder(null); clearError(); } return next; });
126
+ setMethodState((prev) => {
127
+ if (prev !== next) { setOrder(null); forgetHostedOrder(); clearError(); }
128
+ return next;
129
+ });
336
130
  }, [clearError]);
337
- const goToPayment = useCallback(() => {
338
- clearError();
339
- const complaint = buyerGateError(validation, buyer, buyerFields, taxIdOnFile);
340
- if (complaint) { failure.fail(complaint); return; }
341
- // Persist the buyer's details HERE, on "Continuar" not when a payment is
342
- // raised. Everything after this step can fail (no provider configured, a
343
- // declined card, an abandoned PIX, a closed tab) and the details must
344
- // survive all of it. Fire-and-forget on purpose: making the buyer wait on
345
- // the write — or blocking them when it fails — would trade the bug for a
346
- // worse one. Gated on the "salvar meus dados" consent (LGPD), which is
347
- // what the checkbox means.
348
- if (saveProfile) {
349
- saveBuyerContact?.({ name: buyer.name, phone: buyer.phone, taxId: buyer.taxId });
350
- }
351
- setStep("payment");
352
- }, [buyer, buyerFields, saveProfile, taxIdOnFile, clearError, saveBuyerContact, validation]);
353
- const startPayment = useCallback(async (chosen: PaymentMethod, override?: BuyerInfo) => {
354
- clearError();
355
- setCreating(true);
356
- const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
357
- setCreating(false);
358
- if (!result.ok) { failure.fail(result.error); return; }
359
- if (handOverToProvider(result.data, navigate, tenantSlug)) return;
360
- setOrder(result.data);
361
- setFinalStatus(null);
362
- }, [buyer, saveProfile, createOrder, clearError, navigate, tenantSlug]);
131
+ const goToPayment = useGoToPayment({
132
+ buyer, buyerFields, taxIdOnFile, saveProfile, saveBuyerContact, validation, failure, setStep,
133
+ });
134
+ const startPayment = useStartPayment({
135
+ buyer, saveProfile, createOrder, navigate, tenantSlug, basket, failure,
136
+ setCreating, setDecline, setOrder, setFinalStatus,
137
+ });
363
138
  const payWithEmail = useCallback((email: string) => {
364
139
  if (!method) return;
365
140
  const next = { ...buyer, email };
366
141
  setBuyerState(next);
367
142
  void startPayment(method, next);
368
143
  }, [buyer, method, startPayment]);
369
- const handleResolved = useCallback((s: OrderStatus) => { setFinalStatus(s); setStep("status"); }, []);
370
- const retry = useCallback(() => {
371
- setOrder(null); setFinalStatus(null); clearError(); setStep("payment");
372
- }, [clearError]);
144
+ const handleResolved = useCallback((s: OrderStatus, refusal?: CheckoutDecline | null) => {
145
+ setDecline(refusal ?? null);
146
+ setFinalStatus(s);
147
+ setStep("status");
148
+ }, []);
149
+ const retry = useRetryAction({
150
+ decline, order, clearError, setOrder, setDecline, setFinalStatus, setStep, setFreshInstrument,
151
+ });
373
152
  const completed = useMemo(() => new Set(STEP_ORDER.slice(0, STEP_ORDER.indexOf(step))), [step]);
374
- usePaidPort(finalStatus ?? resume.status, onPaid);
153
+ useSettledPort(finalStatus ?? resume.status, onPaid);
375
154
 
376
155
  return {
377
156
  step, setStep, method, setMethod, buyer, setBuyer, saveProfile, setSaveProfile,
378
157
  order, finalStatus: finalStatus ?? resume.status, creating,
158
+ decline, freshInstrument,
379
159
  ...resumeSurface(resume),
380
160
  createError: failure.message, errorField: failure.field, errorCode: failure.code,
381
161
  goToMenu: onExitToMenu, back, editBuyer,