@12-apps/payments-frontend 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -5
- package/src/card/stripe-token.ts +3 -0
- package/src/card/tokenize.ts +17 -3
- package/src/components/checkout/card-instruments.ts +222 -0
- package/src/components/checkout/card-view.tsx +56 -6
- package/src/components/checkout/checkout-flow.tsx +1 -0
- package/src/components/checkout/checkout-steps.tsx +13 -54
- package/src/components/checkout/client.ts +21 -3
- package/src/components/checkout/failure-codes.ts +17 -0
- package/src/components/checkout/method-capability.ts +83 -9
- package/src/components/checkout/payment-error-panel.tsx +97 -0
- package/src/components/checkout/types.ts +39 -4
- package/src/components/checkout/use-card-checkout.ts +77 -91
- package/src/components/checkout/use-checkout-controller.ts +33 -6
- package/src/result.ts +15 -2
- package/eslint.config.js +0 -34
- package/src/__tests__/checkout-confirmation.test.tsx +0 -177
- package/src/__tests__/connection-state.test.tsx +0 -84
- package/src/__tests__/context.test.tsx +0 -88
- package/src/__tests__/controlled-provider.test.tsx +0 -116
- package/src/__tests__/credential-confirm.test.tsx +0 -193
- package/src/__tests__/initial-provider.test.tsx +0 -81
- package/src/__tests__/provider-priority-list.test.tsx +0 -159
- package/src/__tests__/provider-status-bar.test.tsx +0 -152
- package/src/__tests__/slugged-provider.test.tsx +0 -108
- package/src/__tests__/verification-slot.test.tsx +0 -125
- package/src/card/tokenize.test.ts +0 -194
- package/src/components/checkout/__tests__/card-3ds-handover.test.tsx +0 -147
- package/src/components/checkout/__tests__/clear-cart-on-paid.test.tsx +0 -64
- package/src/components/checkout/__tests__/hosted-return.test.ts +0 -109
- package/src/components/checkout/__tests__/method-capability.test.tsx +0 -120
- package/src/components/checkout/__tests__/payments-unavailable.test.tsx +0 -53
- package/src/components/checkout/__tests__/save-on-continue.test.tsx +0 -165
- package/src/components/checkout/__tests__/second-host.test.tsx +0 -86
|
@@ -12,19 +12,37 @@ import { tokenizerFor, type CardTokenizationConfig } from "../../card";
|
|
|
12
12
|
import type { CheckoutProviderConfig, PaymentMethod } from "./types";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Whether the
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* Whether the store's CHAIN gives this browser a card path (FUT-697/563).
|
|
16
|
+
*
|
|
17
|
+
* Asked of the whole chain, for the same reason the server's
|
|
18
|
+
* `usesHostedCheckout` is: the head no longer decides which surface the buyer
|
|
19
|
+
* gets. A store where nobody tokenizes in the browser hands the buyer over at
|
|
20
|
+
* order creation and the card is typed on the provider's own page — a card
|
|
21
|
+
* path, so CARD stays offered. A store where somebody DOES tokenize gets our
|
|
22
|
+
* form instead, and then the question is whether this browser can actually
|
|
23
|
+
* mint for one of those entries: a scheme {@link tokenizerFor} knows, with a
|
|
24
|
+
* key (or PagBank's on-demand refresh), or server-granted stub mode.
|
|
25
|
+
*
|
|
26
|
+
* Answering "yes, it is REDIRECT" off the head alone is what let the picker
|
|
27
|
+
* offer a card the submit could never tokenize.
|
|
28
|
+
*
|
|
29
|
+
* `null` config (still loading / fetch blip) fails OPEN for the UI — the
|
|
30
|
+
* tokenizer itself still fails CLOSED.
|
|
20
31
|
*/
|
|
21
32
|
export function cardPathAvailable(config: CheckoutProviderConfig | null): boolean {
|
|
22
33
|
if (!config) return true;
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
|
|
34
|
+
const chain = cardChain(config);
|
|
35
|
+
if (!chain.some((link) => link.mintable)) return true;
|
|
36
|
+
return chain.some(canMintFor);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether this browser can really produce an instrument for ONE chain entry. */
|
|
40
|
+
function canMintFor(link: CardChainLink): boolean {
|
|
41
|
+
if (!link.mintable) return false;
|
|
42
|
+
if (link.mockTokenization) return true;
|
|
43
|
+
const scheme = link.provider ? tokenizerFor(link.provider) : null;
|
|
26
44
|
if (!scheme) return false;
|
|
27
|
-
return
|
|
45
|
+
return link.publicKey !== null || scheme === "pagbank-sdk";
|
|
28
46
|
}
|
|
29
47
|
|
|
30
48
|
/**
|
|
@@ -38,6 +56,62 @@ export function cardTokenization(config: CheckoutProviderConfig | null): CardTok
|
|
|
38
56
|
return { provider: "pagbank", publicKey: null, mockTokenization: false };
|
|
39
57
|
}
|
|
40
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Tokenization schemes that give the BROWSER something to mint (FUT-563).
|
|
61
|
+
* A `REDIRECT` provider's own page takes the card and a `NONE` one asks for no
|
|
62
|
+
* instrument at all — asking either for a token would produce a fake one under
|
|
63
|
+
* stub mode and an error everywhere else.
|
|
64
|
+
*/
|
|
65
|
+
const MINTABLE: ReadonlySet<string> = new Set(["PUBLIC_KEY", "SDK"]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* ONE entry of the store's chain as the card path sees it (FUT-563): how to
|
|
69
|
+
* mint an instrument for it, plus whether this browser is asked to mint at all.
|
|
70
|
+
*/
|
|
71
|
+
export interface CardChainLink extends CardTokenizationConfig {
|
|
72
|
+
/**
|
|
73
|
+
* The entry declares an in-browser scheme (`PUBLIC_KEY` / `SDK`).
|
|
74
|
+
*
|
|
75
|
+
* `false` entries are NOT dropped from the list, and that is the point: the
|
|
76
|
+
* charge still WALKS them, and how many providers the walk may reach is what
|
|
77
|
+
* decides whether the card charge has to carry `tokensByProvider` at all. A
|
|
78
|
+
* chain counted by "how many instruments the browser happened to mint" hides
|
|
79
|
+
* a hosted-page provider from the server, which then reads the bare token as
|
|
80
|
+
* the head's and skips the one entry that needed no instrument.
|
|
81
|
+
*/
|
|
82
|
+
mintable: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The store's chain as one tokenization config per entry, in the merchant's
|
|
87
|
+
* order (FUT-563) — what the card path mints `tokensByProvider` from.
|
|
88
|
+
*
|
|
89
|
+
* A card instrument is bound to whoever minted it, so a charge can only fail
|
|
90
|
+
* over onto a provider the browser also tokenized for. Entries with no
|
|
91
|
+
* in-browser scheme are carried but marked {@link CardChainLink.mintable}
|
|
92
|
+
* false rather than mocked: they need no instrument, and the gateway passes an
|
|
93
|
+
* unattributed charge straight to them.
|
|
94
|
+
*
|
|
95
|
+
* Falls back to the HEAD triple when the host served no chain, which is exactly
|
|
96
|
+
* the pre-FUT-563 behaviour — one instrument, for the active provider.
|
|
97
|
+
*/
|
|
98
|
+
export function cardChain(config: CheckoutProviderConfig | null): CardChainLink[] {
|
|
99
|
+
if (!config) return [];
|
|
100
|
+
const chain = config.chain;
|
|
101
|
+
if (!chain || chain.length === 0) {
|
|
102
|
+
// No `tokenization` served either (an older host) ⇒ assume the head mints,
|
|
103
|
+
// which is what this checkout did before there was a chain to read.
|
|
104
|
+
const mintable = config.tokenization === null || MINTABLE.has(config.tokenization);
|
|
105
|
+
return [{ ...cardTokenization(config), mintable }];
|
|
106
|
+
}
|
|
107
|
+
return chain.map((link) => ({
|
|
108
|
+
provider: link.provider,
|
|
109
|
+
publicKey: link.publicKey,
|
|
110
|
+
mockTokenization: link.mockTokenization,
|
|
111
|
+
mintable: MINTABLE.has(link.tokenization),
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
41
115
|
/**
|
|
42
116
|
* The methods the picker may offer, from the chain's declared capabilities
|
|
43
117
|
* (FUT-698). `null` config — still loading, or a fetch blip — fails OPEN like
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import { useState, type JSX } from "react";
|
|
3
|
+
|
|
4
|
+
import { UNRESOLVED_CODE } from "./failure-codes";
|
|
5
|
+
import { useCheckoutComponents } from "./ui";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Order-creation failure shown inline on Pagamento — the buyer never leaves the
|
|
9
|
+
* step. Split out of `checkout-steps.tsx` when it grew a second presentation.
|
|
10
|
+
*
|
|
11
|
+
* Three shapes, and which one renders is decided by the failure's CODE, never
|
|
12
|
+
* by its prose:
|
|
13
|
+
*
|
|
14
|
+
* - the buyer e-mail was rejected (the owner testing with the store's own
|
|
15
|
+
* address) — offer a different e-mail to pay with;
|
|
16
|
+
* - the charge is UNRESOLVED — a warning, and NO retry. Some provider may be
|
|
17
|
+
* holding the buyer's money, so "Tentar novamente" mints a second order at
|
|
18
|
+
* a new reference, outside the walk's re-probe of the old one: precisely
|
|
19
|
+
* the double payment the message forbids, offered as the panel's most
|
|
20
|
+
* prominent affordance;
|
|
21
|
+
* - anything else — the ordinary danger Alert with a retry.
|
|
22
|
+
*/
|
|
23
|
+
export function PaymentErrorPanel({
|
|
24
|
+
message,
|
|
25
|
+
emailFlagged,
|
|
26
|
+
code,
|
|
27
|
+
onUseEmail,
|
|
28
|
+
onRetry,
|
|
29
|
+
}: {
|
|
30
|
+
message: string;
|
|
31
|
+
emailFlagged: boolean;
|
|
32
|
+
/** The refusal's machine code, when the server sent one. */
|
|
33
|
+
code?: string | null;
|
|
34
|
+
onUseEmail: (email: string) => void;
|
|
35
|
+
onRetry: () => void;
|
|
36
|
+
}): JSX.Element {
|
|
37
|
+
const { Alert } = useCheckoutComponents();
|
|
38
|
+
const unresolved = code === UNRESOLVED_CODE;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
42
|
+
<Alert
|
|
43
|
+
variant={unresolved ? "warning" : "danger"}
|
|
44
|
+
title={unresolved ? "Estamos confirmando seu pagamento" : "Não foi possível continuar"}
|
|
45
|
+
description={message}
|
|
46
|
+
showIcon
|
|
47
|
+
data-testid={unresolved ? "checkout-unresolved" : "checkout-error"}
|
|
48
|
+
/>
|
|
49
|
+
{unresolved ? null : <RetryAffordance {...{ emailFlagged, onUseEmail, onRetry }} />}
|
|
50
|
+
</Box>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What the buyer can do about a failure that IS safe to retry. */
|
|
55
|
+
function RetryAffordance({
|
|
56
|
+
emailFlagged,
|
|
57
|
+
onUseEmail,
|
|
58
|
+
onRetry,
|
|
59
|
+
}: {
|
|
60
|
+
emailFlagged: boolean;
|
|
61
|
+
onUseEmail: (email: string) => void;
|
|
62
|
+
onRetry: () => void;
|
|
63
|
+
}): JSX.Element {
|
|
64
|
+
const { Button, Input } = useCheckoutComponents();
|
|
65
|
+
const [altEmail, setAltEmail] = useState("");
|
|
66
|
+
|
|
67
|
+
if (!emailFlagged) {
|
|
68
|
+
return (
|
|
69
|
+
<Box>
|
|
70
|
+
<Button variant="solid" color="primary" size="md" onClick={onRetry} dataTestId="checkout-retry-payment">
|
|
71
|
+
Tentar novamente
|
|
72
|
+
</Button>
|
|
73
|
+
</Box>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return (
|
|
77
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
78
|
+
<Input
|
|
79
|
+
label="E-mail para o pagamento"
|
|
80
|
+
type="email"
|
|
81
|
+
variant="outlined"
|
|
82
|
+
size="md"
|
|
83
|
+
fullWidth
|
|
84
|
+
autoComplete="email"
|
|
85
|
+
placeholder="use um e-mail diferente do da loja"
|
|
86
|
+
value={altEmail}
|
|
87
|
+
onChange={(event) => setAltEmail(event.target.value)}
|
|
88
|
+
data-testid="checkout-alt-email"
|
|
89
|
+
/>
|
|
90
|
+
<Box>
|
|
91
|
+
<Button variant="solid" color="primary" size="md" disabled={!altEmail.trim()} onClick={() => onUseEmail(altEmail.trim())} dataTestId="checkout-use-alt-email">
|
|
92
|
+
Usar este e-mail e continuar
|
|
93
|
+
</Button>
|
|
94
|
+
</Box>
|
|
95
|
+
</Box>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -176,6 +176,16 @@ export interface ChargeCardInput {
|
|
|
176
176
|
orderId: string;
|
|
177
177
|
/** A fresh token, or a saved card's id for reuse. */
|
|
178
178
|
token: string;
|
|
179
|
+
/**
|
|
180
|
+
* One instrument per provider, keyed by provider name (FUT-563).
|
|
181
|
+
*
|
|
182
|
+
* A card token is bound to whoever minted it, so a store with a failover
|
|
183
|
+
* chain needs one per entry: without them the server can only ever attempt
|
|
184
|
+
* the provider the browser tokenized for, and the walk skips the rest rather
|
|
185
|
+
* than hand them a blob they cannot read. Omitted for a saved card (its owner
|
|
186
|
+
* is the vault that holds it) and for a store with a single provider.
|
|
187
|
+
*/
|
|
188
|
+
tokensByProvider?: Record<string, string>;
|
|
179
189
|
/** Persist the token for future purchases (backend saves it against the buyer). */
|
|
180
190
|
saveCard: boolean;
|
|
181
191
|
/**
|
|
@@ -189,10 +199,24 @@ export interface ChargeCardInput {
|
|
|
189
199
|
}
|
|
190
200
|
|
|
191
201
|
/**
|
|
192
|
-
*
|
|
193
|
-
* it
|
|
194
|
-
*
|
|
195
|
-
|
|
202
|
+
* ONE enabled provider of the store's chain (FUT-563), as the buyer's config
|
|
203
|
+
* answers it. The same protocol facts as the head, stated per provider —
|
|
204
|
+
* which is what lets the card path mint an instrument for each of them.
|
|
205
|
+
*/
|
|
206
|
+
export interface CheckoutChainLink {
|
|
207
|
+
provider: string;
|
|
208
|
+
tokenization: "NONE" | "PUBLIC_KEY" | "SDK" | "REDIRECT";
|
|
209
|
+
publicKey: string | null;
|
|
210
|
+
mockTokenization: boolean;
|
|
211
|
+
methods: ("PIX" | "CARD" | "BOLETO")[];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The store's payment protocol, as `GET /api/checkout/config` answers it
|
|
216
|
+
* (FUT-697 / FUT-563). Extends the tokenization triple the card path consumes
|
|
217
|
+
* with `tokenization`, which the method picker reads (`REDIRECT` ⇒ the
|
|
218
|
+
* provider's hosted page takes the card, so no in-browser card form is
|
|
219
|
+
* needed), and with the whole enabled `chain`.
|
|
196
220
|
*/
|
|
197
221
|
export interface CheckoutProviderConfig {
|
|
198
222
|
/** Active provider's name, or `null` when the store has none connected. */
|
|
@@ -209,6 +233,17 @@ export interface CheckoutProviderConfig {
|
|
|
209
233
|
* never offers PIX. Empty when no provider is connected.
|
|
210
234
|
*/
|
|
211
235
|
methods: ("PIX" | "CARD" | "BOLETO")[];
|
|
236
|
+
/**
|
|
237
|
+
* EVERY enabled provider, in the merchant's own failover order (FUT-563) —
|
|
238
|
+
* the order the server will walk. The client is a READER of it: it never
|
|
239
|
+
* sorts, filters or re-heads this list, because the order is the merchant's
|
|
240
|
+
* decision and the server has already truncated it to the plan's ceiling.
|
|
241
|
+
*
|
|
242
|
+
* Optional so a config served by an older host (or a mocked one) still
|
|
243
|
+
* renders: absent, the card path behaves exactly as it did before, minting
|
|
244
|
+
* for the head alone.
|
|
245
|
+
*/
|
|
246
|
+
chain?: CheckoutChainLink[];
|
|
212
247
|
}
|
|
213
248
|
|
|
214
249
|
/**
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
NEW_CARD,
|
|
5
5
|
detectBrand,
|
|
6
6
|
onlyDigits,
|
|
7
|
-
tokenizeForCheckout,
|
|
8
7
|
tokenizerFor,
|
|
9
8
|
validateCardNumber,
|
|
10
9
|
validateCvv,
|
|
@@ -14,83 +13,23 @@ import {
|
|
|
14
13
|
type CardDetails,
|
|
15
14
|
type CardFieldErrors,
|
|
16
15
|
type CardTokenizationConfig,
|
|
17
|
-
type CardToken,
|
|
18
16
|
type SavedCard,
|
|
19
17
|
} from "../../card";
|
|
20
18
|
import { ok, type Result } from "../../result";
|
|
21
19
|
|
|
20
|
+
import { resolveNewCardToken, type CardInstruments } from "./card-instruments";
|
|
22
21
|
import {
|
|
23
22
|
chargeCard,
|
|
24
23
|
listSavedCards,
|
|
25
24
|
refreshCardPublicKey,
|
|
26
25
|
} from "./client";
|
|
27
26
|
import { rememberHostedOrder } from "./hosted-return";
|
|
28
|
-
import type {
|
|
29
|
-
|
|
30
|
-
CheckoutOrder,
|
|
31
|
-
OrderStatus,
|
|
32
|
-
SavedCardMeta,
|
|
33
|
-
} from "./types";
|
|
27
|
+
import type { CardChainLink } from "./method-capability";
|
|
28
|
+
import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
|
|
34
29
|
import { usePaymentPolling } from "./use-payment-polling";
|
|
35
30
|
|
|
36
31
|
const EMPTY_CARD: CardDetails = { number: "", holder: "", expiry: "", cvv: "" };
|
|
37
32
|
|
|
38
|
-
/**
|
|
39
|
-
* Tokenize a new card in the ACTIVE provider's protocol (FUT-697), self-healing
|
|
40
|
-
* a rotated public key (FUT-174): the card has already passed local validation,
|
|
41
|
-
* so a real-key encryption failure most likely means the store's key rotated.
|
|
42
|
-
* Refresh the store's key once and retry before surfacing the error;
|
|
43
|
-
* `onKeyRefreshed` caches the new key for the session. The refresh is scoped to
|
|
44
|
-
* the buyer's OWN `orderId` (the route derives the store from it server-side),
|
|
45
|
-
* never a client-supplied store id — and only PagBank can mint a key on demand,
|
|
46
|
-
* so the self-heal is gated on its scheme.
|
|
47
|
-
*/
|
|
48
|
-
async function tokenizeNewCard(
|
|
49
|
-
card: CardDetails,
|
|
50
|
-
config: CardTokenizationConfig,
|
|
51
|
-
orderId: string,
|
|
52
|
-
onKeyRefreshed: (key: string) => void,
|
|
53
|
-
): Promise<Result<CardToken>> {
|
|
54
|
-
const first = await tokenizeForCheckout(card, config);
|
|
55
|
-
if (first.ok || !config.publicKey) return first;
|
|
56
|
-
if (config.provider === null || tokenizerFor(config.provider) !== "pagbank-sdk") return first;
|
|
57
|
-
|
|
58
|
-
const refreshed = await refreshCardPublicKey({ orderId });
|
|
59
|
-
if (refreshed.ok && refreshed.data.publicKey && refreshed.data.publicKey !== config.publicKey) {
|
|
60
|
-
onKeyRefreshed(refreshed.data.publicKey);
|
|
61
|
-
return tokenizeForCheckout(card, { ...config, publicKey: refreshed.data.publicKey });
|
|
62
|
-
}
|
|
63
|
-
return first;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Non-sensitive display metadata for saving a card (the PAN never leaves the form). */
|
|
67
|
-
function toCardMeta(card: CardDetails, token: CardToken): SavedCardMeta {
|
|
68
|
-
const [mm = "", yy = ""] = card.expiry.split("/");
|
|
69
|
-
return {
|
|
70
|
-
brand: token.brand,
|
|
71
|
-
last4: token.last4,
|
|
72
|
-
expMonth: Number(mm),
|
|
73
|
-
expYear: 2000 + Number(yy),
|
|
74
|
-
holder: card.holder.trim(),
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** The charge token for a new card (tokenize + self-heal), plus optional save-meta. */
|
|
79
|
-
async function resolveNewCardToken(
|
|
80
|
-
card: CardDetails,
|
|
81
|
-
config: CardTokenizationConfig,
|
|
82
|
-
orderId: string,
|
|
83
|
-
onKeyRefreshed: (key: string) => void,
|
|
84
|
-
saveCard: boolean,
|
|
85
|
-
): Promise<Result<{ token: string; cardMeta?: SavedCardMeta }>> {
|
|
86
|
-
const tokenized = await tokenizeNewCard(card, config, orderId, onKeyRefreshed);
|
|
87
|
-
if (!tokenized.ok) return tokenized;
|
|
88
|
-
return ok({
|
|
89
|
-
token: tokenized.data.token,
|
|
90
|
-
...(saveCard ? { cardMeta: toCardMeta(card, tokenized.data) } : {}),
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
|
|
94
33
|
/**
|
|
95
34
|
* Saved-card list + current selection, loaded once on mount — scoped to the
|
|
96
35
|
* current store (FUT-697), so only cards the store's active provider can
|
|
@@ -168,6 +107,8 @@ interface CardCheckout {
|
|
|
168
107
|
saveCard: boolean;
|
|
169
108
|
setSaveCard: (checked: boolean) => void;
|
|
170
109
|
error: string | null;
|
|
110
|
+
/** The failure's machine code, when the server sent one — drives PRESENTATION. */
|
|
111
|
+
errorCode: string | null;
|
|
171
112
|
submitting: boolean;
|
|
172
113
|
submitted: boolean;
|
|
173
114
|
pollError: string | null;
|
|
@@ -189,7 +130,13 @@ interface CardFormState {
|
|
|
189
130
|
/** The submit slice of {@link useCardCheckout}. */
|
|
190
131
|
type CardSubmit = Pick<
|
|
191
132
|
CardCheckout,
|
|
192
|
-
|
|
133
|
+
| "submitting"
|
|
134
|
+
| "submitted"
|
|
135
|
+
| "error"
|
|
136
|
+
| "errorCode"
|
|
137
|
+
| "pollError"
|
|
138
|
+
| "pollTimedOut"
|
|
139
|
+
| "handlePay"
|
|
193
140
|
>;
|
|
194
141
|
|
|
195
142
|
/**
|
|
@@ -209,6 +156,41 @@ function handOverToChallenge(order: CheckoutOrder, url: string): void {
|
|
|
209
156
|
window.location.assign(url);
|
|
210
157
|
}
|
|
211
158
|
|
|
159
|
+
/** The buyer's card is invalid — block the submit and show which field. */
|
|
160
|
+
function blockedByForm(form: CardFormState): boolean {
|
|
161
|
+
if (!form.usingNewCard) return false;
|
|
162
|
+
const errors = form.validate();
|
|
163
|
+
form.setFieldErrors(errors);
|
|
164
|
+
return Object.values(errors).some(Boolean);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* What to charge with: a saved card's vault id, or fresh instruments minted for
|
|
169
|
+
* every provider the walk may reach (FUT-563).
|
|
170
|
+
*
|
|
171
|
+
* A saved card is deliberately never chained — a vault token names a card in
|
|
172
|
+
* whichever provider's vault holds it, which the server resolves; the
|
|
173
|
+
* instruments minted this session say nothing about who that is.
|
|
174
|
+
*/
|
|
175
|
+
async function resolveInstruments(input: {
|
|
176
|
+
form: CardFormState;
|
|
177
|
+
orderId: string;
|
|
178
|
+
config: CardTokenizationConfig;
|
|
179
|
+
onKeyRefreshed: (key: string) => void;
|
|
180
|
+
providerChain: readonly CardChainLink[];
|
|
181
|
+
}): Promise<Result<CardInstruments>> {
|
|
182
|
+
const { form } = input;
|
|
183
|
+
if (!form.usingNewCard) return ok({ token: form.selection });
|
|
184
|
+
return resolveNewCardToken(
|
|
185
|
+
form.card,
|
|
186
|
+
input.config,
|
|
187
|
+
input.orderId,
|
|
188
|
+
input.onKeyRefreshed,
|
|
189
|
+
form.saveCard,
|
|
190
|
+
input.providerChain,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
212
194
|
/**
|
|
213
195
|
* The submit state machine (FUT-58): validate → tokenize (self-heal) → charge →
|
|
214
196
|
* poll for the async confirmation, bubbling the terminal status up via
|
|
@@ -221,10 +203,12 @@ function useCardSubmit(
|
|
|
221
203
|
onResolved: (status: OrderStatus) => void,
|
|
222
204
|
pollIntervalMs: number,
|
|
223
205
|
form: CardFormState,
|
|
206
|
+
providerChain: readonly CardChainLink[],
|
|
224
207
|
): CardSubmit {
|
|
225
208
|
const [submitting, setSubmitting] = useState(false);
|
|
226
209
|
const [submitted, setSubmitted] = useState(false);
|
|
227
210
|
const [error, setError] = useState<string | null>(null);
|
|
211
|
+
const [errorCode, setErrorCode] = useState<string | null>(null);
|
|
228
212
|
// The active key: resolved by the checkout config, overridden by the
|
|
229
213
|
// rotated-key self-heal for the rest of the session.
|
|
230
214
|
const { publicKey, setPublicKey } = useCardPublicKey(order.orderId, providerConfig);
|
|
@@ -239,27 +223,19 @@ function useCardSubmit(
|
|
|
239
223
|
if (status && status !== "AWAITING_PAYMENT") onResolved(status);
|
|
240
224
|
}, [status, onResolved]);
|
|
241
225
|
|
|
242
|
-
async function resolveToken(): Promise<Result<{ token: string; cardMeta?: SavedCardMeta }>> {
|
|
243
|
-
if (!form.usingNewCard) return ok({ token: form.selection }); // reuse saved card's token id
|
|
244
|
-
return resolveNewCardToken(
|
|
245
|
-
form.card,
|
|
246
|
-
{ ...providerConfig, publicKey },
|
|
247
|
-
order.orderId,
|
|
248
|
-
setPublicKey,
|
|
249
|
-
form.saveCard,
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
226
|
const handlePay = async (): Promise<void> => {
|
|
254
227
|
setError(null);
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
form.setFieldErrors(errors);
|
|
258
|
-
if (Object.values(errors).some(Boolean)) return; // block submit until valid
|
|
259
|
-
}
|
|
228
|
+
setErrorCode(null);
|
|
229
|
+
if (blockedByForm(form)) return;
|
|
260
230
|
setSubmitting(true);
|
|
261
231
|
|
|
262
|
-
const resolved = await
|
|
232
|
+
const resolved = await resolveInstruments({
|
|
233
|
+
form,
|
|
234
|
+
orderId: order.orderId,
|
|
235
|
+
config: { ...providerConfig, publicKey },
|
|
236
|
+
onKeyRefreshed: setPublicKey,
|
|
237
|
+
providerChain,
|
|
238
|
+
});
|
|
263
239
|
if (!resolved.ok) {
|
|
264
240
|
setError(resolved.error);
|
|
265
241
|
setSubmitting(false);
|
|
@@ -269,12 +245,16 @@ function useCardSubmit(
|
|
|
269
245
|
const charged = await chargeCard({
|
|
270
246
|
orderId: order.orderId,
|
|
271
247
|
token: resolved.data.token,
|
|
248
|
+
tokensByProvider: resolved.data.tokensByProvider,
|
|
272
249
|
saveCard: form.usingNewCard && form.saveCard,
|
|
273
250
|
cardMeta: resolved.data.cardMeta,
|
|
274
251
|
taxId: buyer.taxId,
|
|
275
252
|
});
|
|
276
253
|
if (!charged.ok) {
|
|
277
254
|
setError(charged.error); // transport/validation problem — stay on the form
|
|
255
|
+
// An UNRESOLVED charge is not a decline: some provider may be holding the
|
|
256
|
+
// money, so the view must not dress it as one (`card-view.tsx`).
|
|
257
|
+
setErrorCode(charged.code ?? null);
|
|
278
258
|
setSubmitting(false);
|
|
279
259
|
return;
|
|
280
260
|
}
|
|
@@ -289,7 +269,7 @@ function useCardSubmit(
|
|
|
289
269
|
else setSubmitted(true);
|
|
290
270
|
};
|
|
291
271
|
|
|
292
|
-
return { submitting, submitted, error, pollError, pollTimedOut, handlePay };
|
|
272
|
+
return { submitting, submitted, error, errorCode, pollError, pollTimedOut, handlePay };
|
|
293
273
|
}
|
|
294
274
|
|
|
295
275
|
/**
|
|
@@ -304,6 +284,11 @@ export function useCardCheckout(
|
|
|
304
284
|
pollIntervalMs: number,
|
|
305
285
|
/** The store whose saved cards may be offered (host routing owns the slug). */
|
|
306
286
|
tenantSlug?: string,
|
|
287
|
+
/**
|
|
288
|
+
* The merchant's ordered provider chain (FUT-563) — one instrument is minted
|
|
289
|
+
* per entry so a card charge can fail over. Omitted ⇒ the head alone.
|
|
290
|
+
*/
|
|
291
|
+
providerChain: readonly CardChainLink[] = [],
|
|
307
292
|
): CardCheckout {
|
|
308
293
|
const { savedCards, selection, setSelection } = useSavedCards(tenantSlug);
|
|
309
294
|
const [card, setCard] = useState<CardDetails>(EMPTY_CARD);
|
|
@@ -320,14 +305,15 @@ export function useCardCheckout(
|
|
|
320
305
|
cvv: validateCvv(card.cvv, brand),
|
|
321
306
|
});
|
|
322
307
|
|
|
323
|
-
const submit = useCardSubmit(
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
setFieldErrors,
|
|
330
|
-
|
|
308
|
+
const submit = useCardSubmit(
|
|
309
|
+
order,
|
|
310
|
+
buyer,
|
|
311
|
+
providerConfig,
|
|
312
|
+
onResolved,
|
|
313
|
+
pollIntervalMs,
|
|
314
|
+
{ card, usingNewCard, selection, saveCard, validate, setFieldErrors },
|
|
315
|
+
providerChain,
|
|
316
|
+
);
|
|
331
317
|
|
|
332
318
|
return {
|
|
333
319
|
savedCards,
|
|
@@ -159,6 +159,33 @@ function usePaidPort(settled: OrderStatus | null, onPaid: (() => void) | undefin
|
|
|
159
159
|
}, [settled, onPaid]);
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* The create-order refusal the steps render: what to say, which field to
|
|
164
|
+
* highlight, and the machine CODE that decides how it is presented — an
|
|
165
|
+
* unresolved charge is not a failed one, and the Pagamento step must not offer
|
|
166
|
+
* it a "Tentar novamente" (FUT-563). One hook so the three always move
|
|
167
|
+
* together; they were three `useState`s that could be cleared apart.
|
|
168
|
+
*/
|
|
169
|
+
function useCreateFailure() {
|
|
170
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
171
|
+
const [field, setField] = useState<BuyerField | null>(null);
|
|
172
|
+
const [code, setCode] = useState<string | null>(null);
|
|
173
|
+
const clear = useCallback(() => {
|
|
174
|
+
setMessage(null);
|
|
175
|
+
setField(null);
|
|
176
|
+
setCode(null);
|
|
177
|
+
}, []);
|
|
178
|
+
const fail = useCallback(
|
|
179
|
+
(next: { message: string; field?: BuyerField | null; code?: string }) => {
|
|
180
|
+
setMessage(next.message);
|
|
181
|
+
setField(next.field ?? null);
|
|
182
|
+
setCode(next.code ?? null);
|
|
183
|
+
},
|
|
184
|
+
[],
|
|
185
|
+
);
|
|
186
|
+
return { message, field, code, clear, fail };
|
|
187
|
+
}
|
|
188
|
+
|
|
162
189
|
/**
|
|
163
190
|
* All checkout state + handlers, so the checkout flow stays presentational.
|
|
164
191
|
* - `setMethod` drops any order raised for the previous method (no stale QR/form).
|
|
@@ -195,10 +222,9 @@ export function useCheckoutController(
|
|
|
195
222
|
const [order, setOrder] = useState<CheckoutOrder | null>(resume.order);
|
|
196
223
|
const [finalStatus, setFinalStatus] = useState<OrderStatus | null>(null);
|
|
197
224
|
const [creating, setCreating] = useState(false);
|
|
198
|
-
const
|
|
199
|
-
const [errorField, setErrorField] = useState<BuyerField | null>(null);
|
|
225
|
+
const failure = useCreateFailure();
|
|
200
226
|
|
|
201
|
-
const clearError =
|
|
227
|
+
const clearError = failure.clear;
|
|
202
228
|
const setBuyer = useCallback((next: BuyerInfo) => { setBuyerState(next); clearError(); }, [clearError]);
|
|
203
229
|
const { back, editBuyer } = useCheckoutNav(taxIdOnFile, onExitToMenu, setStep);
|
|
204
230
|
const setMethod = useCallback((next: PaymentMethod) => {
|
|
@@ -207,7 +233,7 @@ export function useCheckoutController(
|
|
|
207
233
|
const goToPayment = useCallback(() => {
|
|
208
234
|
clearError();
|
|
209
235
|
const cpfError = cpfGateError(buyer.taxId?.trim() ?? "", taxIdOnFile);
|
|
210
|
-
if (cpfError) {
|
|
236
|
+
if (cpfError) { failure.fail({ message: cpfError, field: "cpf" }); return; }
|
|
211
237
|
// Persist the buyer's details HERE, on "Continuar" — not when a payment is
|
|
212
238
|
// raised. Everything after this step can fail (no provider configured, a
|
|
213
239
|
// declined card, an abandoned PIX, a closed tab) and the details must
|
|
@@ -225,7 +251,7 @@ export function useCheckoutController(
|
|
|
225
251
|
setCreating(true);
|
|
226
252
|
const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
|
|
227
253
|
setCreating(false);
|
|
228
|
-
if (!result.ok) {
|
|
254
|
+
if (!result.ok) { failure.fail(result.error); return; }
|
|
229
255
|
if (handOverToProvider(result.data)) return;
|
|
230
256
|
setOrder(result.data);
|
|
231
257
|
setFinalStatus(null);
|
|
@@ -245,7 +271,8 @@ export function useCheckoutController(
|
|
|
245
271
|
|
|
246
272
|
return {
|
|
247
273
|
step, setStep, method, setMethod, buyer, setBuyer, saveProfile, setSaveProfile,
|
|
248
|
-
order, finalStatus: finalStatus ?? resume.status, creating,
|
|
274
|
+
order, finalStatus: finalStatus ?? resume.status, creating,
|
|
275
|
+
createError: failure.message, errorField: failure.field, errorCode: failure.code,
|
|
249
276
|
goToMenu: onExitToMenu, back, editBuyer,
|
|
250
277
|
goToPayment, startPayment, payWithEmail, handleResolved, retry, completed,
|
|
251
278
|
};
|
package/src/result.ts
CHANGED
|
@@ -4,8 +4,21 @@
|
|
|
4
4
|
* portable frontend never reaches back into a repo-specific package for a
|
|
5
5
|
* ten-line type.
|
|
6
6
|
*/
|
|
7
|
-
export type Result<T> =
|
|
7
|
+
export type Result<T> =
|
|
8
|
+
| { ok: true; data: T }
|
|
9
|
+
/**
|
|
10
|
+
* `code` is the API envelope's machine code, when the failure came from one
|
|
11
|
+
* (`PAYMENT_UNRESOLVED`, `GATEWAY_UNAVAILABLE`, …). Optional because a
|
|
12
|
+
* browser-side failure — a tokenizer bail, a dropped connection — has none.
|
|
13
|
+
* The MESSAGE is what the buyer reads; the code is what the surface uses to
|
|
14
|
+
* decide how to PRESENT it, which a message cannot be parsed for.
|
|
15
|
+
*/
|
|
16
|
+
| { ok: false; error: string; code?: string };
|
|
8
17
|
|
|
9
18
|
export const ok = <T,>(data: T): Result<T> => ({ ok: true, data });
|
|
10
19
|
|
|
11
|
-
export const err = <T,>(error: string): Result<T> => ({
|
|
20
|
+
export const err = <T,>(error: string, code?: string): Result<T> => ({
|
|
21
|
+
ok: false,
|
|
22
|
+
error,
|
|
23
|
+
...(code ? { code } : {}),
|
|
24
|
+
});
|
package/eslint.config.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { config as baseConfig } from '@12-apps/eslint-config/base';
|
|
2
|
-
import testFlakiness from 'eslint-plugin-test-flakiness';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The everyday DX lint for this package.
|
|
6
|
-
*
|
|
7
|
-
* It registers `eslint-plugin-test-flakiness` with every rule left OFF. Those
|
|
8
|
-
* rules are enforced by the repo-root CI lane (`eslint.flakiness.config.mjs`),
|
|
9
|
-
* not here — but a test file that legitimately suppresses one carries an inline
|
|
10
|
-
* `// eslint-disable-next-line test-flakiness/...` directive, and ESLint reports
|
|
11
|
-
* "Definition for rule not found" for a directive naming a plugin it has never
|
|
12
|
-
* heard of. Registering the plugin lets those directives RESOLVE without this
|
|
13
|
-
* config enforcing anything.
|
|
14
|
-
*
|
|
15
|
-
* Same trick, same reason as the root `eslint.complexity.config.mjs`, which
|
|
16
|
-
* registers `@typescript-eslint` (rules off) so source-file directives resolve
|
|
17
|
-
* inside that isolated gate.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
/** @type {import("eslint").Linter.Config[]} */
|
|
21
|
-
export default [
|
|
22
|
-
...baseConfig,
|
|
23
|
-
{
|
|
24
|
-
files: ['**/__tests__/**', '**/*.test.{ts,tsx}'],
|
|
25
|
-
plugins: { 'test-flakiness': testFlakiness },
|
|
26
|
-
// Registered-but-off means the rule never fires here, which would make
|
|
27
|
-
// every legitimate directive read as "unused". Scoped to test files so the
|
|
28
|
-
// rest of the package still gets dead-directive hygiene.
|
|
29
|
-
linterOptions: { reportUnusedDisableDirectives: 'off' },
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
|
|
33
|
-
},
|
|
34
|
-
];
|