@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.
Files changed (34) hide show
  1. package/package.json +13 -5
  2. package/src/card/stripe-token.ts +3 -0
  3. package/src/card/tokenize.ts +17 -3
  4. package/src/components/checkout/card-instruments.ts +222 -0
  5. package/src/components/checkout/card-view.tsx +56 -6
  6. package/src/components/checkout/checkout-flow.tsx +1 -0
  7. package/src/components/checkout/checkout-steps.tsx +13 -54
  8. package/src/components/checkout/client.ts +21 -3
  9. package/src/components/checkout/failure-codes.ts +17 -0
  10. package/src/components/checkout/method-capability.ts +83 -9
  11. package/src/components/checkout/payment-error-panel.tsx +97 -0
  12. package/src/components/checkout/types.ts +39 -4
  13. package/src/components/checkout/use-card-checkout.ts +77 -91
  14. package/src/components/checkout/use-checkout-controller.ts +33 -6
  15. package/src/result.ts +15 -2
  16. package/eslint.config.js +0 -34
  17. package/src/__tests__/checkout-confirmation.test.tsx +0 -177
  18. package/src/__tests__/connection-state.test.tsx +0 -84
  19. package/src/__tests__/context.test.tsx +0 -88
  20. package/src/__tests__/controlled-provider.test.tsx +0 -116
  21. package/src/__tests__/credential-confirm.test.tsx +0 -193
  22. package/src/__tests__/initial-provider.test.tsx +0 -81
  23. package/src/__tests__/provider-priority-list.test.tsx +0 -159
  24. package/src/__tests__/provider-status-bar.test.tsx +0 -152
  25. package/src/__tests__/slugged-provider.test.tsx +0 -108
  26. package/src/__tests__/verification-slot.test.tsx +0 -125
  27. package/src/card/tokenize.test.ts +0 -194
  28. package/src/components/checkout/__tests__/card-3ds-handover.test.tsx +0 -147
  29. package/src/components/checkout/__tests__/clear-cart-on-paid.test.tsx +0 -64
  30. package/src/components/checkout/__tests__/hosted-return.test.ts +0 -109
  31. package/src/components/checkout/__tests__/method-capability.test.tsx +0 -120
  32. package/src/components/checkout/__tests__/payments-unavailable.test.tsx +0 -53
  33. package/src/components/checkout/__tests__/save-on-continue.test.tsx +0 -165
  34. package/src/components/checkout/__tests__/second-host.test.tsx +0 -86
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
6
6
  "exports": {
@@ -15,7 +15,7 @@
15
15
  "typecheck": "tsc --noEmit"
16
16
  },
17
17
  "dependencies": {
18
- "@12-apps/payments-backend": "^1.3.1",
18
+ "@12-apps/payments-backend": "^1.4.1",
19
19
  "react-qr-code": "^2.2.0"
20
20
  },
21
21
  "peerDependencies": {
@@ -29,8 +29,8 @@
29
29
  "@emotion/react": "^11.14.0",
30
30
  "@emotion/styled": "^11.14.0",
31
31
  "@mui/material": "^6.5.0",
32
- "@12-apps/eslint-config": "^1.4.1",
33
- "@12-apps/typescript-config": "^1.4.1",
32
+ "@12-apps/eslint-config": "^1.5.1",
33
+ "@12-apps/typescript-config": "^1.5.1",
34
34
  "@testing-library/react": "^16.1.0",
35
35
  "@types/react": "19.2.2",
36
36
  "@types/react-dom": "19.2.2",
@@ -61,6 +61,14 @@
61
61
  "prisma",
62
62
  "*.js",
63
63
  "*.mjs",
64
- "*.md"
64
+ "*.md",
65
+ "!eslint.config.js",
66
+ "!**/__tests__/**",
67
+ "!**/tests/**",
68
+ "!**/*.test.*",
69
+ "!**/*.spec.*",
70
+ "!**/*.stories.*",
71
+ "!**/*.test-story.*",
72
+ "!**/test-helpers.*"
65
73
  ]
66
74
  }
@@ -37,6 +37,8 @@ export async function tokenizeWithStripe(
37
37
  publicKey: string,
38
38
  brand: string,
39
39
  last4: string,
40
+ /** Deadline for the round trip; an abort reads as "could not contact". */
41
+ signal?: AbortSignal,
40
42
  ): Promise<Result<CardToken>> {
41
43
  const match = /^(\d{2})\/(\d{2})$/.exec(card.expiry.trim());
42
44
  if (!match) return err("Validade inválida ou expirada.");
@@ -60,6 +62,7 @@ export async function tokenizeWithStripe(
60
62
  Authorization: `Bearer ${publicKey}`,
61
63
  },
62
64
  body: body.toString(),
65
+ signal,
63
66
  });
64
67
  } catch {
65
68
  return err("Não foi possível contatar o provedor do cartão. Verifique sua conexão.");
@@ -157,6 +157,8 @@ async function tokenizeWithPagarme(
157
157
  publicKey: string,
158
158
  brand: string,
159
159
  last4: string,
160
+ /** Deadline for the round trip; an abort reads as "could not contact". */
161
+ signal?: AbortSignal,
160
162
  ): Promise<Result<CardToken>> {
161
163
  const match = /^(\d{2})\/(\d{2})$/.exec(card.expiry.trim());
162
164
  if (!match) return err("Validade inválida ou expirada.");
@@ -166,6 +168,7 @@ async function tokenizeWithPagarme(
166
168
  response = await fetch(`${PAGARME_TOKENS_URL}?appId=${encodeURIComponent(publicKey)}`, {
167
169
  method: "POST",
168
170
  headers: { "Content-Type": "application/json" },
171
+ signal,
169
172
  body: JSON.stringify({
170
173
  type: "card",
171
174
  card: {
@@ -203,6 +206,11 @@ export async function tokenizeCard(
203
206
  card: CardDetails,
204
207
  publicKey: string | null | undefined,
205
208
  tokenizer: CardTokenizer = "pagbank-sdk",
209
+ /**
210
+ * Bounds the network schemes (Pagar.me / Stripe). The PagBank one encrypts
211
+ * locally and has no request to abort.
212
+ */
213
+ signal?: AbortSignal,
206
214
  ): Promise<Result<CardToken>> {
207
215
  const validationError = validateCardInput(card);
208
216
  if (validationError) return err(validationError);
@@ -219,10 +227,10 @@ export async function tokenizeCard(
219
227
  const last4 = pan.slice(-4);
220
228
 
221
229
  if (tokenizer === "pagarme-token") {
222
- return tokenizeWithPagarme(card, pan, publicKey, brand, last4);
230
+ return tokenizeWithPagarme(card, pan, publicKey, brand, last4, signal);
223
231
  }
224
232
  if (tokenizer === "stripe-pm") {
225
- return tokenizeWithStripe(card, pan, publicKey, brand, last4);
233
+ return tokenizeWithStripe(card, pan, publicKey, brand, last4, signal);
226
234
  }
227
235
 
228
236
  if (!(await ensurePagBankSdk())) {
@@ -274,9 +282,15 @@ const CARD_PATH_UNAVAILABLE =
274
282
  export async function tokenizeForCheckout(
275
283
  card: CardDetails,
276
284
  config: CardTokenizationConfig,
285
+ /**
286
+ * Optional deadline for a provider that mints over the network. The chain
287
+ * path passes one so a backup acquirer nobody can reach cannot hold the
288
+ * buyer's Pagar button (FUT-563).
289
+ */
290
+ signal?: AbortSignal,
277
291
  ): Promise<Result<CardToken>> {
278
292
  const scheme = config.provider ? tokenizerFor(config.provider) : null;
279
- if (scheme && config.publicKey) return tokenizeCard(card, config.publicKey, scheme);
293
+ if (scheme && config.publicKey) return tokenizeCard(card, config.publicKey, scheme, signal);
280
294
 
281
295
  if (!config.mockTokenization) return err(CARD_PATH_UNAVAILABLE);
282
296
 
@@ -0,0 +1,222 @@
1
+ import {
2
+ tokenizeForCheckout,
3
+ tokenizerFor,
4
+ type CardDetails,
5
+ type CardToken,
6
+ type CardTokenizationConfig,
7
+ } from "../../card";
8
+ import { err, ok, type Result } from "../../result";
9
+
10
+ import { refreshCardPublicKey } from "./client";
11
+ import type { CardChainLink } from "./method-capability";
12
+ import type { SavedCardMeta } from "./types";
13
+
14
+ /**
15
+ * TOKENIZATION for the buyer's card — one instrument per provider the charge
16
+ * may reach (FUT-563).
17
+ *
18
+ * A card token is bound to whoever minted it: the gateway refuses to hand
19
+ * provider #2 provider #1's blob and skips it instead. So the checkout's whole
20
+ * contribution to card failover is here — take the card the buyer typed ONCE,
21
+ * and produce an instrument for each entry of the chain the server published.
22
+ *
23
+ * Split out of `use-card-checkout.ts` so the hook stays about SUBMIT state:
24
+ * nothing in this module touches React.
25
+ */
26
+
27
+ /**
28
+ * Tokenize a new card in the ACTIVE provider's protocol (FUT-697), self-healing
29
+ * a rotated public key (FUT-174): the card has already passed local validation,
30
+ * so a real-key encryption failure most likely means the store's key rotated.
31
+ * Refresh the store's key once and retry before surfacing the error;
32
+ * `onKeyRefreshed` caches the new key for the session. The refresh is scoped to
33
+ * the buyer's OWN `orderId` (the route derives the store from it server-side),
34
+ * never a client-supplied store id — and only PagBank can mint a key on demand,
35
+ * so the self-heal is gated on its scheme.
36
+ */
37
+ async function tokenizeNewCard(
38
+ card: CardDetails,
39
+ config: CardTokenizationConfig,
40
+ orderId: string,
41
+ onKeyRefreshed: (key: string) => void,
42
+ ): Promise<Result<CardToken>> {
43
+ const first = await tokenizeForCheckout(card, config);
44
+ if (first.ok || !config.publicKey) return first;
45
+ if (config.provider === null || tokenizerFor(config.provider) !== "pagbank-sdk") return first;
46
+
47
+ const refreshed = await refreshCardPublicKey({ orderId });
48
+ if (refreshed.ok && refreshed.data.publicKey && refreshed.data.publicKey !== config.publicKey) {
49
+ onKeyRefreshed(refreshed.data.publicKey);
50
+ return tokenizeForCheckout(card, { ...config, publicKey: refreshed.data.publicKey });
51
+ }
52
+ return first;
53
+ }
54
+
55
+ /** Non-sensitive display metadata for saving a card (the PAN never leaves the form). */
56
+ function toCardMeta(card: CardDetails, token: CardToken): SavedCardMeta {
57
+ const [mm = "", yy = ""] = card.expiry.split("/");
58
+ return {
59
+ brand: token.brand,
60
+ last4: token.last4,
61
+ expMonth: Number(mm),
62
+ expYear: 2000 + Number(yy),
63
+ holder: card.holder.trim(),
64
+ };
65
+ }
66
+
67
+ /**
68
+ * One instrument per provider in the chain (FUT-563), keyed by provider name.
69
+ *
70
+ * A card token is bound to whoever minted it, so a charge can only fail over
71
+ * onto a provider the browser ALSO tokenized for. Every entry is attempted and
72
+ * the failures are simply left out: a provider we could not mint for is one the
73
+ * walk will skip, which is the honest outcome and strictly better than failing
74
+ * the whole payment because the second acquirer's key was missing.
75
+ *
76
+ * Nothing is re-typed and nothing is asked of the buyer twice — the same
77
+ * validated card fields are encrypted once per provider, in the browser.
78
+ */
79
+ async function mintChainInstruments(
80
+ card: CardDetails,
81
+ chain: readonly CardChainLink[],
82
+ timeoutMs: number,
83
+ ): Promise<Record<string, CardToken>> {
84
+ const results = await Promise.all(
85
+ chain.map(async (link) => {
86
+ // A hosted page (`REDIRECT`) or an instrument-free provider (`NONE`) is
87
+ // skipped, never mocked: minting for it would produce a FAKE token under
88
+ // stub mode and an error everywhere else. It still travels in `chain`,
89
+ // because the walk will reach it.
90
+ if (!link.provider || !link.mintable) return null;
91
+ const tokenized = await mintWithDeadline(card, link, timeoutMs);
92
+ return tokenized.ok ? ([link.provider, tokenized.data] as const) : null;
93
+ }),
94
+ );
95
+ return Object.fromEntries(results.filter((entry) => entry !== null));
96
+ }
97
+
98
+ /**
99
+ * How long ONE backup acquirer may hold the buyer's Pagar button.
100
+ *
101
+ * A tokenizer is a cross-origin POST to the acquirer (Pagar.me, Stripe) with
102
+ * no deadline of its own, and browser `fetch` has none either: a middlebox that
103
+ * accepts the socket and never answers leaves the promise pending for as long
104
+ * as the OS keeps the connection, which the buyer sees as a spinning, disabled
105
+ * "Pagar R$ …" with no cancel and no explanation. Bounding it degrades to what
106
+ * this module already documents — a provider we could not mint for is one the
107
+ * walk will skip.
108
+ */
109
+ const MINT_TIMEOUT_MS = 8_000;
110
+
111
+ /** One tail mint, abandoned (and aborted) when the deadline passes. */
112
+ async function mintWithDeadline(
113
+ card: CardDetails,
114
+ link: CardChainLink,
115
+ timeoutMs: number,
116
+ ): Promise<Result<CardToken>> {
117
+ const controller = new AbortController();
118
+ let timer: ReturnType<typeof setTimeout> | undefined;
119
+ // RACED as well as aborted: the abort ends a `fetch`, but the PagBank scheme
120
+ // waits on an injected <script> that can hang with nothing to cancel.
121
+ const deadline = new Promise<Result<CardToken>>((resolve) => {
122
+ timer = setTimeout(() => {
123
+ controller.abort();
124
+ resolve(err("O provedor do cartão não respondeu a tempo."));
125
+ }, timeoutMs);
126
+ });
127
+ try {
128
+ return await Promise.race([tokenizeForCheckout(card, link, controller.signal), deadline]);
129
+ } finally {
130
+ clearTimeout(timer);
131
+ }
132
+ }
133
+
134
+ /** What a submitted card charge carries: the head's token plus the chain's. */
135
+ export interface CardInstruments {
136
+ token: string;
137
+ tokensByProvider?: Record<string, string>;
138
+ cardMeta?: SavedCardMeta;
139
+ }
140
+
141
+ /**
142
+ * The chain entry the bare `token` is minted from — NOT the chain head.
143
+ *
144
+ * `usesHostedCheckout` asks the WHOLE chain whether anybody tokenizes in the
145
+ * browser, so a store headed by a hosted page whose NEXT provider does gets our
146
+ * card form rather than the handover. The head then has no in-browser scheme at
147
+ * all: minting against it returns "o pagamento com cartão está indisponível",
148
+ * and the buyer who just typed a full PAN is refused while the mintable tail —
149
+ * the very provider the form was shown for — is never asked. That is the whole
150
+ * card path of every REDIRECT-headed store that adds a backup provider, and
151
+ * enabling a provider APPENDS it, so the shape arises with no reordering.
152
+ *
153
+ * The ACTIVE provider still wins when it is itself mintable: only its config
154
+ * carries the key this session may have self-healed (FUT-174).
155
+ *
156
+ * Falls back to the active config when nothing in the chain can be minted for
157
+ * — the tokenizer then says so in the buyer's own words, which is the honest
158
+ * answer for a store that has no in-browser card path at all.
159
+ */
160
+ function mintingConfig(
161
+ config: CardTokenizationConfig,
162
+ chain: readonly CardChainLink[],
163
+ ): CardTokenizationConfig {
164
+ const mintable = chain.filter((link) => link.mintable && link.provider);
165
+ if (mintable.length === 0) return config;
166
+ return mintable.some((link) => link.provider === config.provider) ? config : mintable[0]!;
167
+ }
168
+
169
+ /** The charge token for a new card (tokenize + self-heal), plus optional save-meta. */
170
+ export async function resolveNewCardToken(
171
+ card: CardDetails,
172
+ config: CardTokenizationConfig,
173
+ orderId: string,
174
+ onKeyRefreshed: (key: string) => void,
175
+ saveCard: boolean,
176
+ chain: readonly CardChainLink[],
177
+ /** Per-entry mint deadline. Overridable so tests need not wait it out. */
178
+ timeoutMs: number = MINT_TIMEOUT_MS,
179
+ ): Promise<Result<CardInstruments>> {
180
+ // No chain served (an older host, or a fetch blip): the active provider
181
+ // alone, exactly the pre-FUT-563 behaviour.
182
+ const entries = chain.length > 0 ? chain : [{ ...config, mintable: true }];
183
+ const head = mintingConfig(config, entries);
184
+ // The self-heal rides along and stays PagBank-only — `tokenizeNewCard` gates
185
+ // on its scheme, so a chain headed elsewhere cannot ask for someone's key.
186
+ // Head and tail mint CONCURRENTLY. Sequentially, one unreachable backup
187
+ // acquirer held a healthy head's charge for as long as the network stack
188
+ // allowed — the failover feature blocking on the provider it exists to fall
189
+ // back to. The head keeps no deadline: it is the provider being paid, and
190
+ // its self-heal is a second round trip of our own.
191
+ const rest = entries.filter((link) => link.provider !== head.provider);
192
+ const [headToken, tail] = await Promise.all([
193
+ tokenizeNewCard(card, head, orderId, onKeyRefreshed),
194
+ mintChainInstruments(card, rest, timeoutMs),
195
+ ]);
196
+ // A failure in the tail is not fatal: that provider is simply one the walk
197
+ // will skip.
198
+ const minted = { ...tail };
199
+ if (headToken.ok && head.provider) minted[head.provider] = headToken.data;
200
+
201
+ // Refused only when NO entry could be minted for. While one still can, the
202
+ // charge goes out and the entries we hold nothing for are skipped by name.
203
+ const anyMinted = Object.values(minted);
204
+ if (!headToken.ok && anyMinted.length === 0) return headToken;
205
+ const primary = headToken.ok ? headToken.data : anyMinted[0]!;
206
+ const tokensByProvider = Object.fromEntries(
207
+ Object.entries(minted).map(([provider, instrument]) => [provider, instrument.token]),
208
+ );
209
+ return ok({
210
+ token: primary.token,
211
+ // Sent whenever the WALK has more than one provider to reach — counted on
212
+ // the chain the server published, never on how many instruments happened
213
+ // to be minted (FUT-563). A hosted-page provider mints nothing by design,
214
+ // so counting the map drops it for the two-provider store it exists for:
215
+ // the bare token is then read as the chain HEAD's and every other entry is
216
+ // refused as "holding someone else's instrument", including the one that
217
+ // needed none. A genuinely single-provider store still sends exactly what
218
+ // it sent before.
219
+ ...(entries.length > 1 ? { tokensByProvider } : {}),
220
+ ...(saveCard ? { cardMeta: toCardMeta(card, primary) } : {}),
221
+ });
222
+ }
@@ -8,6 +8,8 @@ import {
8
8
  type CardTokenizationConfig,
9
9
  } from "../../card";
10
10
 
11
+ import { UNRESOLVED_CODE } from "./failure-codes";
12
+ import type { CardChainLink } from "./method-capability";
11
13
  import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
12
14
  import { useCheckoutComponents } from "./ui";
13
15
  import { useCardCheckout } from "./use-card-checkout";
@@ -58,6 +60,38 @@ function SubmittedState({
58
60
  );
59
61
  }
60
62
 
63
+ /**
64
+ * What a submit came back with.
65
+ *
66
+ * An UNRESOLVED charge gets its own presentation (FUT-563): some provider may
67
+ * be holding the buyer's money, so wording it as a failure — and heading it
68
+ * "Não foi possível pagar" above a body that says "não pague de novo" — pushes
69
+ * the buyer toward exactly the second payment it forbids.
70
+ */
71
+ function ChargeFailure({
72
+ message,
73
+ unresolved,
74
+ }: {
75
+ message: string;
76
+ unresolved: boolean;
77
+ }): JSX.Element {
78
+ const { Alert } = useCheckoutComponents();
79
+ if (unresolved) {
80
+ return (
81
+ <Alert
82
+ variant="warning"
83
+ title="Estamos confirmando seu pagamento"
84
+ description={message}
85
+ showIcon
86
+ data-testid="card-unresolved"
87
+ />
88
+ );
89
+ }
90
+ return (
91
+ <Alert variant="danger" title="Não foi possível pagar" description={message} showIcon data-testid="card-error" />
92
+ );
93
+ }
94
+
61
95
  /**
62
96
  * Card payment view (FUT-58). Card data is validated + formatted client-side, then
63
97
  * tokenized (mock PagBank JS SDK) so the PAN never reaches our server; only the
@@ -72,6 +106,7 @@ export function CardView({
72
106
  order,
73
107
  buyer = {},
74
108
  providerConfig,
109
+ providerChain,
75
110
  tenantSlug,
76
111
  onResolved,
77
112
  pollIntervalMs = 2500,
@@ -80,13 +115,24 @@ export function CardView({
80
115
  buyer?: BuyerInfo;
81
116
  /** The active provider's tokenization protocol + key (FUT-697). */
82
117
  providerConfig: CardTokenizationConfig;
118
+ /**
119
+ * Every provider the charge may WALK, in the merchant's order (FUT-563) —
120
+ * one instrument is minted per entry so the charge survives the first
121
+ * provider failing. Omitted ⇒ the head alone, as before.
122
+ */
123
+ providerChain?: CardChainLink[];
83
124
  /** Scopes the saved-card list to cards the store's provider can charge. */
84
125
  tenantSlug?: string;
85
126
  onResolved: (status: OrderStatus) => void;
86
127
  pollIntervalMs?: number;
87
128
  }): JSX.Element {
88
- const { Alert, Text } = useCheckoutComponents();
89
- const cc = useCardCheckout(order, buyer, providerConfig, onResolved, pollIntervalMs, tenantSlug);
129
+ const { Text } = useCheckoutComponents();
130
+ const cc = useCardCheckout(order, buyer, providerConfig, onResolved, pollIntervalMs, tenantSlug, providerChain);
131
+ // A charge NOBODY can confirm yet is not a decline (FUT-563). Some provider
132
+ // may be holding the buyer's money, so it gets its own presentation: the
133
+ // danger heading "Não foi possível pagar" contradicts the body's "não pague
134
+ // de novo" and pushes the buyer toward exactly the retry it forbids.
135
+ const unresolved = cc.errorCode === UNRESOLVED_CODE;
90
136
 
91
137
  if (cc.submitted) {
92
138
  return <SubmittedState pollError={cc.pollError} pollTimedOut={cc.pollTimedOut} />;
@@ -118,11 +164,15 @@ export function CardView({
118
164
  />
119
165
  ) : null}
120
166
 
121
- {cc.error ? (
122
- <Alert variant="danger" title="Não foi possível pagar" description={cc.error} showIcon data-testid="card-error" />
123
- ) : null}
167
+ {cc.error ? <ChargeFailure message={cc.error} unresolved={unresolved} /> : null}
124
168
 
125
- <CardPayBar totalLabel={order.totalLabel} submitting={cc.submitting} onPay={() => void cc.handlePay()} />
169
+ {/* The pay bar is GONE while a charge is unresolved, not merely disabled
170
+ with a spinner: paying again is the one action the message forbids,
171
+ and a live "Pagar R$ …" directly under "não pague de novo" is what
172
+ the buyer's thumb reaches for. */}
173
+ {unresolved ? null : (
174
+ <CardPayBar totalLabel={order.totalLabel} submitting={cc.submitting} onPay={() => void cc.handlePay()} />
175
+ )}
126
176
  </Box>
127
177
  );
128
178
  }
@@ -161,6 +161,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
161
161
  creating={c.creating}
162
162
  createError={c.createError}
163
163
  errorField={c.errorField}
164
+ errorCode={c.errorCode}
164
165
  onGenerate={(chosen) => void c.startPayment(chosen)}
165
166
  onUseEmail={c.payWithEmail}
166
167
  // Set only for a skipped-Dados flow (the controller decides); the
@@ -1,16 +1,18 @@
1
1
  import { Box } from "@mui/material";
2
- import { useEffect, useRef, useState, type JSX, type ReactNode } from "react";
2
+ import { useEffect, useRef, type JSX, type ReactNode } from "react";
3
3
 
4
4
  import { BuyerInfoForm } from "./buyer-info-form";
5
5
  import { CardView } from "./card-view";
6
6
  import { LockOutlinedIcon } from "./icons";
7
7
  import {
8
+ cardChain,
8
9
  cardPathAvailable,
9
10
  cardTokenization,
10
11
  offeredMethods,
11
12
  usePreselectSoleMethod,
12
13
  } from "./method-capability";
13
14
  import { MethodPicker } from "./method-picker";
15
+ import { PaymentErrorPanel } from "./payment-error-panel";
14
16
  import { PayerSummary } from "./payer-summary";
15
17
  import { PixView } from "./pix-view";
16
18
  import type {
@@ -74,6 +76,9 @@ function PaymentBody({
74
76
  order={order}
75
77
  buyer={buyer}
76
78
  providerConfig={cardTokenization(providerConfig)}
79
+ // The whole chain (FUT-563): one instrument is minted per provider so
80
+ // the charge survives the first one failing, with nothing re-typed.
81
+ providerChain={cardChain(providerConfig)}
77
82
  tenantSlug={tenantSlug}
78
83
  onResolved={onResolved}
79
84
  pollIntervalMs={pollIntervalMs}
@@ -229,6 +234,11 @@ interface PaymentStepProps {
229
234
  creating: boolean;
230
235
  createError: string | null;
231
236
  errorField: BuyerField | null;
237
+ /**
238
+ * The refusal's machine code (FUT-563). An UNRESOLVED charge is not a failed
239
+ * one — the panel below must not offer to raise a second.
240
+ */
241
+ errorCode?: string | null;
232
242
  onGenerate: (method: PaymentMethod) => void;
233
243
  onUseEmail: (email: string) => void;
234
244
  /**
@@ -262,6 +272,7 @@ export function PaymentStep({
262
272
  creating,
263
273
  createError,
264
274
  errorField,
275
+ errorCode,
265
276
  onGenerate,
266
277
  onUseEmail,
267
278
  onEditBuyer,
@@ -304,6 +315,7 @@ export function PaymentStep({
304
315
  <PaymentErrorPanel
305
316
  message={createError}
306
317
  emailFlagged={errorField === "email"}
318
+ code={errorCode}
307
319
  onUseEmail={onUseEmail}
308
320
  onRetry={() => onGenerate(method)}
309
321
  />
@@ -311,56 +323,3 @@ export function PaymentStep({
311
323
  </Box>
312
324
  );
313
325
  }
314
-
315
- /**
316
- * Order-creation failure (non-field) shown inline on Pagamento with a retry — the
317
- * buyer never leaves the step. When the buyer e-mail was rejected (owner testing
318
- * with the store's own e-mail) it offers a different e-mail to pay with.
319
- */
320
- function PaymentErrorPanel({
321
- message,
322
- emailFlagged,
323
- onUseEmail,
324
- onRetry,
325
- }: {
326
- message: string;
327
- emailFlagged: boolean;
328
- onUseEmail: (email: string) => void;
329
- onRetry: () => void;
330
- }): JSX.Element {
331
- const { Alert, Button, Input } = useCheckoutComponents();
332
- const [altEmail, setAltEmail] = useState("");
333
-
334
- return (
335
- <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
336
- <Alert variant="danger" title="Não foi possível continuar" description={message} showIcon data-testid="checkout-error" />
337
- {emailFlagged ? (
338
- <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
339
- <Input
340
- label="E-mail para o pagamento"
341
- type="email"
342
- variant="outlined"
343
- size="md"
344
- fullWidth
345
- autoComplete="email"
346
- placeholder="use um e-mail diferente do da loja"
347
- value={altEmail}
348
- onChange={(event) => setAltEmail(event.target.value)}
349
- data-testid="checkout-alt-email"
350
- />
351
- <Box>
352
- <Button variant="solid" color="primary" size="md" disabled={!altEmail.trim()} onClick={() => onUseEmail(altEmail.trim())} dataTestId="checkout-use-alt-email">
353
- Usar este e-mail e continuar
354
- </Button>
355
- </Box>
356
- </Box>
357
- ) : (
358
- <Box>
359
- <Button variant="solid" color="primary" size="md" onClick={onRetry} dataTestId="checkout-retry-payment">
360
- Tentar novamente
361
- </Button>
362
- </Box>
363
- )}
364
- </Box>
365
- );
366
- }
@@ -31,6 +31,23 @@ import type {
31
31
  interface ApiEnvelope<T> {
32
32
  data?: T;
33
33
  error?: string;
34
+ /**
35
+ * Stable machine code for the failure (`checkoutErrorResponse` always sends
36
+ * one). Carried through so a surface can PRESENT a refusal for what it is —
37
+ * an unresolved charge is not a decline, and rendering it under "não foi
38
+ * possível pagar" with a live pay button invites the second payment its own
39
+ * text forbids.
40
+ */
41
+ code?: string;
42
+ }
43
+
44
+ /**
45
+ * A non-2xx envelope as a {@link Result} failure, carrying its machine CODE.
46
+ * The message is what the buyer reads; the code is what a surface uses to
47
+ * decide how to PRESENT it, which a message cannot be parsed for.
48
+ */
49
+ function refused<T>(json: ApiEnvelope<T> | null): Result<T> {
50
+ return err(json?.error ?? "Não foi possível concluir a operação. Tente novamente.", json?.code);
34
51
  }
35
52
 
36
53
  /** Call an API route and normalize the response into a {@link Result}. */
@@ -41,9 +58,7 @@ async function requestResult<T>(input: string, init?: RequestInit): Promise<Resu
41
58
  headers: { "Content-Type": "application/json", ...init?.headers },
42
59
  });
43
60
  const json = (await res.json().catch(() => null)) as ApiEnvelope<T> | null;
44
- if (!res.ok) {
45
- return err(json?.error ?? "Não foi possível concluir a operação. Tente novamente.");
46
- }
61
+ if (!res.ok) return refused(json);
47
62
  if (!json || json.data === undefined) {
48
63
  return err(json?.error ?? "Resposta inválida do servidor.");
49
64
  }
@@ -137,6 +152,9 @@ export async function chargeCard(input: ChargeCardInput): Promise<Result<ChargeO
137
152
  body: JSON.stringify({
138
153
  orderId: input.orderId,
139
154
  token: input.token,
155
+ // One instrument per provider (FUT-563) — the server hands each provider
156
+ // in the chain its own, which is what lets a card charge fail over.
157
+ ...(input.tokensByProvider ? { tokensByProvider: input.tokensByProvider } : {}),
140
158
  saveCard: input.saveCard,
141
159
  cardMeta: input.cardMeta,
142
160
  taxId: input.taxId,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Checkout failure codes the BUYER SURFACE presents differently (FUT-563).
3
+ *
4
+ * Everything else the server refuses is one thing to a shopper — "it did not
5
+ * work, try again" — and one danger Alert says it. These are the exceptions:
6
+ * codes whose correct rendering is not an error at all, and which a message
7
+ * string cannot be parsed for without turning copy edits into behaviour
8
+ * changes.
9
+ */
10
+
11
+ /**
12
+ * The charge is IN DOUBT, not failed: some provider may be holding the buyer's
13
+ * money and no probe could say. It is the one outcome where inviting a retry
14
+ * is actively harmful, so the surface must drop every affordance that offers
15
+ * one and must not word it as a failure.
16
+ */
17
+ export const UNRESOLVED_CODE = "PAYMENT_UNRESOLVED";