@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.
- package/package.json +2 -2
- package/src/components/checkout/basket.ts +85 -0
- package/src/components/checkout/card-outcome.ts +81 -0
- package/src/components/checkout/card-view.tsx +62 -22
- package/src/components/checkout/checkout-actions.ts +341 -0
- package/src/components/checkout/checkout-flow.tsx +112 -18
- package/src/components/checkout/checkout-steps.tsx +149 -174
- package/src/components/checkout/checkout-totals.tsx +51 -0
- package/src/components/checkout/client-context.tsx +3 -0
- package/src/components/checkout/dados-step.tsx +141 -0
- package/src/components/checkout/decline.ts +48 -0
- package/src/components/checkout/en-US.ts +33 -0
- package/src/components/checkout/hosted-return.ts +190 -206
- package/src/components/checkout/hosted-store.ts +269 -0
- package/src/components/checkout/payment-status-parts.tsx +311 -0
- package/src/components/checkout/payment-status.tsx +69 -264
- package/src/components/checkout/poll-loop.ts +10 -12
- package/src/components/checkout/poll-rearm.ts +86 -0
- package/src/components/checkout/providers/types.ts +20 -3
- package/src/components/checkout/pt-BR.ts +33 -0
- package/src/components/checkout/screens-copy.ts +14 -0
- package/src/components/checkout/screens-en-US.ts +1 -0
- package/src/components/checkout/screens-pt-BR.ts +3 -0
- package/src/components/checkout/transport.ts +21 -1
- package/src/components/checkout/types.ts +24 -0
- package/src/components/checkout/use-card-checkout.ts +31 -31
- package/src/components/checkout/use-checkout-controller.ts +51 -271
- package/src/components/checkout/use-hosted-resume.ts +326 -0
- package/src/components/checkout/view-copy.ts +32 -0
- package/src/components/checkout/wallet-pane.tsx +3 -0
- package/src/flows/create-payment-flows.tsx +7 -0
- package/src/flows/screens-hosted.tsx +19 -2
- 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
|
|
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 {
|
|
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(
|
|
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:
|
|
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
|
|
325
|
-
// an accepted charge begins polling
|
|
326
|
-
if (charged.data.status !== "AWAITING_PAYMENT")
|
|
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:
|
|
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,
|
|
2
|
-
|
|
3
|
-
import {
|
|
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 {
|
|
7
|
-
import { useCheckoutNavigate
|
|
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 {
|
|
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>(
|
|
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>(
|
|
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) => {
|
|
126
|
+
setMethodState((prev) => {
|
|
127
|
+
if (prev !== next) { setOrder(null); forgetHostedOrder(); clearError(); }
|
|
128
|
+
return next;
|
|
129
|
+
});
|
|
336
130
|
}, [clearError]);
|
|
337
|
-
const goToPayment =
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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,
|