@12-apps/payments-frontend 1.3.0 → 1.4.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.
@@ -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 ACTIVE provider gives this browser a card path (FUT-697):
16
- * a scheme {@link tokenizerFor} knows, with a key (or PagBank's on-demand
17
- * refresh); a hosted page (`REDIRECT` the provider's own site takes the
18
- * card); or server-granted stub mode. `null` config (still loading / fetch
19
- * blip) fails OPEN for the UI the tokenizer itself still fails CLOSED.
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
- if (config.mockTokenization) return true;
24
- if (config.tokenization === "REDIRECT") return true;
25
- const scheme = config.provider ? tokenizerFor(config.provider) : null;
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 config.publicKey !== null || scheme === "pagbank-sdk";
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
- * The store's active payment protocol, as `GET /api/checkout/config` answers
193
- * it (FUT-697). Extends the tokenization triple the card path consumes with
194
- * `tokenization`, which the method picker reads (`REDIRECT` the provider's
195
- * hosted page takes the card, so no in-browser card form is needed).
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
- BuyerInfo,
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
- "submitting" | "submitted" | "error" | "pollError" | "pollTimedOut" | "handlePay"
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
- if (form.usingNewCard) {
256
- const errors = form.validate();
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 resolveToken();
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(order, buyer, providerConfig, onResolved, pollIntervalMs, {
324
- card,
325
- usingNewCard,
326
- selection,
327
- saveCard,
328
- validate,
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 [createError, setCreateError] = useState<string | null>(null);
199
- const [errorField, setErrorField] = useState<BuyerField | null>(null);
225
+ const failure = useCreateFailure();
200
226
 
201
- const clearError = useCallback(() => { setCreateError(null); setErrorField(null); }, []);
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) { setCreateError(cpfError); setErrorField("cpf"); return; }
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) { setCreateError(result.error.message); setErrorField(result.error.field); return; }
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, createError, errorField,
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> = { ok: true; data: T } | { ok: false; error: string };
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> => ({ ok: false, error });
20
+ export const err = <T,>(error: string, code?: string): Result<T> => ({
21
+ ok: false,
22
+ error,
23
+ ...(code ? { code } : {}),
24
+ });