@12-apps/payments-frontend 1.7.1 → 1.8.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 CHANGED
@@ -1,21 +1,23 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "1.7.1",
3
+ "version": "1.8.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": {
7
7
  ".": "./src/index.ts"
8
8
  },
9
9
  "scripts": {
10
- "clean": "rm -rf node_modules coverage",
10
+ "clean": "rm -rf node_modules coverage storybook-static",
11
11
  "test": "node ../../../scripts/vitest-with-teardown.mjs run",
12
12
  "test:watch": "vitest watch",
13
13
  "lint": "eslint src --max-warnings 0",
14
14
  "check-types": "tsc --noEmit",
15
- "typecheck": "tsc --noEmit"
15
+ "typecheck": "tsc --noEmit",
16
+ "dev": "storybook dev -p 6007",
17
+ "storybook:build": "storybook build"
16
18
  },
17
19
  "dependencies": {
18
- "@12-apps/payments-backend": "^1.7.1",
20
+ "@12-apps/payments-backend": "^1.8.1",
19
21
  "react-qr-code": "^2.2.0"
20
22
  },
21
23
  "peerDependencies": {
@@ -26,20 +28,27 @@
26
28
  "react-dom": ">=19.0.0"
27
29
  },
28
30
  "devDependencies": {
31
+ "@12-apps/eslint-config": "^1.9.1",
32
+ "@12-apps/typescript-config": "^1.9.1",
29
33
  "@emotion/react": "^11.14.0",
30
34
  "@emotion/styled": "^11.14.0",
31
35
  "@mui/material": "^6.5.0",
32
- "@12-apps/eslint-config": "^1.8.1",
33
- "@12-apps/typescript-config": "^1.8.1",
36
+ "@storybook/addon-docs": "^9.1.10",
37
+ "@storybook/addon-links": "^9.1.10",
38
+ "@storybook/builder-vite": "^9.1.10",
39
+ "@storybook/react-vite": "^9.1.10",
34
40
  "@testing-library/react": "^16.1.0",
35
41
  "@types/react": "19.2.2",
36
42
  "@types/react-dom": "19.2.2",
37
43
  "eslint": "^9.39.1",
44
+ "eslint-plugin-storybook": "9.1.10",
38
45
  "eslint-plugin-test-flakiness": "^1.4.0",
39
46
  "jsdom": "^25.0.1",
40
47
  "react": "^19.2.0",
41
48
  "react-dom": "^19.2.0",
49
+ "storybook": "^9.1.10",
42
50
  "typescript": "^5.9.2",
51
+ "vite": "^6.4.1",
43
52
  "vitest": "^3.2.4"
44
53
  },
45
54
  "engines": {
@@ -63,6 +72,8 @@
63
72
  "*.mjs",
64
73
  "*.md",
65
74
  "!eslint.config.js",
75
+ "!.storybook/**",
76
+ "!src/stories/**",
66
77
  "!**/__tests__/**",
67
78
  "!**/tests/**",
68
79
  "!**/*.test.*",
@@ -0,0 +1,117 @@
1
+ /**
2
+ * WHAT THE BUYER MUST BE ASKED, derived from the chain's own declaration
3
+ * (FUT-595, wired into the browser by FUT-741).
4
+ *
5
+ * The backend has published `customerSchema` per chain entry since FUT-740 and
6
+ * validates every charge against it. The browser ignored it and asked for a CPF
7
+ * and nothing else — right for one provider, wrong for a store whose provider
8
+ * wants a mobile, and wrong in the expensive direction: the buyer finishes the
9
+ * form, presses Pagar, and gets a 400 naming a field there was never an input
10
+ * for. That is the third FUT-740 critical, one layer up.
11
+ *
12
+ * ## The degrade direction is the whole safety property
13
+ *
14
+ * A chain that declares nothing is NOT a chain that asks for nothing. An older
15
+ * host, a hand-written config, a config that failed to load — all of them
16
+ * arrive here as "no declaration", and the honest reading of that is "I do not
17
+ * know", not "nothing is needed". So the fallback is today's behaviour: CPF,
18
+ * required. Over-asking costs the buyer one field they may not have needed;
19
+ * under-asking costs them a completed form and a refused charge.
20
+ *
21
+ * ## The union, not the head
22
+ *
23
+ * Every entry the walk may reach gets a vote, because the form is filled ONCE,
24
+ * before the first attempt. `required` takes the strictest answer and the TYPE
25
+ * takes the narrower rule (`MOBILE` ⊂ `PHONE`) for the same reason: a value
26
+ * that satisfies only the laxer member strands the buyer on the stricter one.
27
+ * This mirrors the server's `unionCustomerFields` exactly — deliberately, since
28
+ * the two must not disagree about what a chain needs.
29
+ */
30
+
31
+ import { validateCpf } from "../../card";
32
+
33
+ import type { CheckoutChainLink, CheckoutCustomerField, PaymentMethod } from "./types";
34
+
35
+ /** What a chain that declared nothing is assumed to need — today's behaviour. */
36
+ const CPF_REQUIRED: CheckoutCustomerField = { key: "taxId", type: "CPF", required: true };
37
+
38
+ /** The fields one entry asks for when charging via `method`. */
39
+ function fieldsOf(
40
+ link: CheckoutChainLink,
41
+ method: PaymentMethod | null,
42
+ ): readonly CheckoutCustomerField[] {
43
+ const declared = link.customerSchema ?? [];
44
+ // No method chosen yet (the Dados step opens before the picker): collect the
45
+ // union across every method, which is the up-front collection FUT-595 asks
46
+ // for — a form that has to be re-opened after the method is picked is the
47
+ // double typing this exists to remove.
48
+ if (method === null) return declared;
49
+ return declared.filter((field) => !field.methods || field.methods.includes(method));
50
+ }
51
+
52
+ /** Which type accepts a SUBSET of the other's values. `MOBILE` ⊂ `PHONE`. */
53
+ function narrowerType(
54
+ a: CheckoutCustomerField["type"],
55
+ b: CheckoutCustomerField["type"],
56
+ ): CheckoutCustomerField["type"] {
57
+ if (a === "PHONE" && b === "MOBILE") return "MOBILE";
58
+ return a;
59
+ }
60
+
61
+ /** Merge one declaration into the running union. */
62
+ function absorb(
63
+ byKey: Map<CheckoutCustomerField["key"], CheckoutCustomerField>,
64
+ field: CheckoutCustomerField,
65
+ ): void {
66
+ const existing = byKey.get(field.key);
67
+ if (!existing) {
68
+ byKey.set(field.key, { key: field.key, type: field.type, required: field.required });
69
+ return;
70
+ }
71
+ byKey.set(field.key, {
72
+ ...existing,
73
+ type: narrowerType(existing.type, field.type),
74
+ required: existing.required || field.required,
75
+ });
76
+ }
77
+
78
+ /**
79
+ * The buyer fields to collect for this chain and method.
80
+ *
81
+ * An entry with NO declaration makes the answer uncertain, so the CPF is folded
82
+ * in as required — see the module doc. That is deliberately not the same as
83
+ * "the chain is empty": an empty chain means the store cannot charge at all,
84
+ * and the caller renders the unavailable screen rather than a form.
85
+ */
86
+ export function buyerFieldsFor(
87
+ chain: readonly CheckoutChainLink[] | undefined,
88
+ method: PaymentMethod | null,
89
+ ): CheckoutCustomerField[] {
90
+ if (!chain || chain.length === 0) return [CPF_REQUIRED];
91
+ const byKey = new Map<CheckoutCustomerField["key"], CheckoutCustomerField>();
92
+ // An UNDECLARED entry is not a silent one. It is an entry whose requirements
93
+ // this browser cannot see, and the safe reading of that is the pre-FUT-595
94
+ // rule it would have been charged under.
95
+ if (chain.some((link) => link.customerSchema === undefined)) absorb(byKey, CPF_REQUIRED);
96
+ for (const field of chain.flatMap((link) => fieldsOf(link, method))) absorb(byKey, field);
97
+ return [...byKey.values()];
98
+ }
99
+
100
+ /** Whether a value satisfies one field's declared rule. */
101
+ const RULES: Record<CheckoutCustomerField["type"], (value: string) => boolean> = {
102
+ NAME: (value) => value.trim().length >= 2,
103
+ EMAIL: (value) => /^[^\s@]+@[^\s@]+$/.test(value) && /\.[^\s@.]+$/.test(value),
104
+ // DDD + an 8-digit landline or a 9-digit mobile, mirroring the server's rule.
105
+ PHONE: (value) => /^\d{10,11}$/.test(value.replace(/\D/g, "")),
106
+ MOBILE: (value) => /^\d{2}9\d{8}$/.test(value.replace(/\D/g, "")),
107
+ // The real check-digit rule, not a length test: the same validator the CPF
108
+ // input has always used, so the form and the gate cannot disagree.
109
+ CPF: (value) => validateCpf(value) === undefined,
110
+ };
111
+
112
+ /** Whether a declared field is satisfied by what the buyer typed. */
113
+ export function fieldSatisfied(field: CheckoutCustomerField, value: string | undefined): boolean {
114
+ const typed = (value ?? "").trim();
115
+ if (!typed) return !field.required;
116
+ return RULES[field.type](typed);
117
+ }
@@ -3,7 +3,8 @@ import type { JSX } from "react";
3
3
 
4
4
  import { formatCpf, validateCpf } from "../../card";
5
5
 
6
- import type { BuyerField, BuyerInfo } from "./types";
6
+ import { fieldSatisfied } from "./buyer-fields";
7
+ import type { BuyerField, BuyerInfo, CheckoutCustomerField } from "./types";
7
8
  import { useCheckoutComponents } from "./ui";
8
9
 
9
10
  const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -18,121 +19,189 @@ interface BuyerFieldErrors {
18
19
  phone?: string;
19
20
  }
20
21
 
22
+ /** The four inputs this form can render, keyed by the schema's own key. */
23
+ const INPUTS = {
24
+ taxId: {
25
+ label: "CPF",
26
+ testId: "buyer-cpf",
27
+ errorKey: "cpf",
28
+ props: {
29
+ type: "text",
30
+ inputMode: "numeric",
31
+ autoComplete: "off",
32
+ placeholder: "000.000.000-00",
33
+ },
34
+ },
35
+ name: { label: "Nome", testId: "buyer-name", errorKey: "name", props: { type: "text", autoComplete: "name" } },
36
+ email: { label: "E-mail", testId: "buyer-email", errorKey: "email", props: { type: "email", autoComplete: "email" } },
37
+ phone: { label: "Telefone", testId: "buyer-phone", errorKey: "phone", props: { type: "tel", autoComplete: "tel" } },
38
+ } as const;
39
+
40
+ type InputKey = keyof typeof INPUTS;
41
+
42
+ /** The order the inputs are shown in — CPF first, as it always has been. */
43
+ const FIELD_ORDER: readonly InputKey[] = ["taxId", "name", "email", "phone"];
44
+
45
+ /**
46
+ * The contact fields the checkout offers regardless of what any provider asks
47
+ * for: they are the RECEIPT's, not the charge's. A chain that requires none of
48
+ * them still gets them as optional inputs, which is what they have always been.
49
+ */
50
+ const RECEIPT_FIELDS: readonly InputKey[] = ["name", "email", "phone"];
51
+
52
+ /** What the buyer typed for one key. */
53
+ function valueOf(buyer: BuyerInfo, key: InputKey): string {
54
+ return buyer[key] ?? "";
55
+ }
56
+
21
57
  /**
22
58
  * Per-field error, overlaying any server-flagged error on top of the local format
23
59
  * checks — so a failed "pay" attempt highlights the exact input.
24
60
  */
25
- function deriveErrors(value: BuyerInfo, fieldError: FieldError): BuyerFieldErrors {
61
+ function deriveErrors(
62
+ value: BuyerInfo,
63
+ fieldError: FieldError,
64
+ required: ReadonlySet<InputKey>,
65
+ ): BuyerFieldErrors {
26
66
  const override = (field: BuyerField): string | undefined =>
27
67
  fieldError?.field === field ? fieldError.message : undefined;
28
68
  const localEmail =
29
69
  value.email && !EMAIL_PATTERN.test(value.email) ? "E-mail inválido." : undefined;
70
+ const missing = (key: InputKey, message: string): string | undefined =>
71
+ required.has(key) && !valueOf(value, key).trim() ? message : undefined;
30
72
  return {
31
73
  cpf: override("cpf") ?? (value.taxId ? validateCpf(value.taxId) : undefined),
32
- email: override("email") ?? localEmail,
33
- name: override("name"),
34
- phone: override("phone"),
74
+ email: override("email") ?? localEmail ?? missing("email", "E-mail obrigatório."),
75
+ name: override("name") ?? missing("name", "Nome obrigatório."),
76
+ phone: override("phone") ?? missing("phone", "Telefone obrigatório."),
35
77
  };
36
78
  }
37
79
 
38
80
  /**
39
- * Buyer contact at checkout. CPF is REQUIRED (PagBank needs it for the charge);
40
- * name, e-mail and phone are optional and only used for the receipt. A provided
41
- * e-mail is format-checked. Fully controlled by the parent checkout state.
81
+ * The instruction line above the inputs, worded from what the chain actually
82
+ * declared. It used to name the CPF unconditionally, which is wrong the moment
83
+ * a store's provider wants something else (or nothing).
42
84
  */
43
- export function BuyerInfoForm({
85
+ function instructionFor(required: ReadonlySet<InputKey>): string {
86
+ const names = FIELD_ORDER.filter((key) => required.has(key)).map((key) =>
87
+ key === "taxId" ? "CPF" : INPUTS[key].label.toLowerCase(),
88
+ );
89
+ if (names.length === 0) {
90
+ return "Nome, e-mail e telefone são opcionais — usados apenas para o comprovante.";
91
+ }
92
+ return (
93
+ `Informe seu ${names.join(", ")} (${names.length === 1 ? "obrigatório" : "obrigatórios"} ` +
94
+ "para o pagamento). Os demais campos são opcionais — usados apenas para o comprovante."
95
+ );
96
+ }
97
+
98
+ /** Which inputs to render, and which of them are required. */
99
+ function resolveShape(fields: readonly CheckoutCustomerField[] | undefined): {
100
+ shown: InputKey[];
101
+ required: Set<InputKey>;
102
+ } {
103
+ // No declaration reaching this component means the pre-FUT-595 form: CPF
104
+ // required, receipt fields optional. `buyerFieldsFor` degrades the same way,
105
+ // so a caller that derived its fields from a chain lands here too.
106
+ const declared = fields ?? [{ key: "taxId" as const, type: "CPF" as const, required: true }];
107
+ const required = new Set<InputKey>(
108
+ declared.filter((field) => field.required).map((field) => field.key),
109
+ );
110
+ const declaredKeys = new Set<InputKey>(declared.map((field) => field.key));
111
+ const shown = FIELD_ORDER.filter(
112
+ (key) => declaredKeys.has(key) || RECEIPT_FIELDS.includes(key),
113
+ );
114
+ return { shown, required };
115
+ }
116
+
117
+ /** Whether every declared requirement is met — the "Continuar" gate's question. */
118
+ export function buyerFormComplete(
119
+ buyer: BuyerInfo,
120
+ fields: readonly CheckoutCustomerField[],
121
+ ): CheckoutCustomerField | null {
122
+ return fields.find((field) => !fieldSatisfied(field, buyer[field.key])) ?? null;
123
+ }
124
+
125
+ /** One declaration-driven input, rendered through the host's slot. */
126
+ function BuyerInput({
127
+ fieldKey,
44
128
  value,
129
+ required,
130
+ error,
45
131
  onChange,
46
- fieldError,
47
132
  }: {
133
+ fieldKey: InputKey;
48
134
  value: BuyerInfo;
135
+ required: boolean;
136
+ error?: string;
49
137
  onChange: (buyer: BuyerInfo) => void;
50
- fieldError?: FieldError;
51
138
  }): JSX.Element {
52
- const { Input, Text } = useCheckoutComponents();
53
- const errors = deriveErrors(value, fieldError ?? null);
54
-
139
+ const { Input } = useCheckoutComponents();
140
+ const spec = INPUTS[fieldKey];
55
141
  return (
56
- <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
57
- <Text variant="caption" size="xs" color="secondary" as="p">
58
- Informe seu CPF (obrigatório para o pagamento). Nome, e-mail e telefone são
59
- opcionais — usados apenas para o comprovante.
60
- </Text>
61
-
62
- <Input
63
- label="CPF"
64
- type="text"
65
- inputMode="numeric"
66
- variant="outlined"
67
- size="md"
68
- fullWidth
69
- required
70
- autoComplete="off"
71
- placeholder="000.000.000-00"
72
- value={value.taxId ?? ""}
73
- error={Boolean(errors.cpf)}
74
- helperText={errors.cpf}
75
- onChange={(event) => onChange({ ...value, taxId: formatCpf(event.target.value) })}
76
- data-testid="buyer-cpf"
77
- />
78
-
79
- <OptionalContactFields value={value} onChange={onChange} errors={errors} />
80
- </Box>
142
+ <Input
143
+ label={spec.label}
144
+ variant="outlined"
145
+ size="md"
146
+ fullWidth
147
+ required={required}
148
+ {...spec.props}
149
+ value={valueOf(value, fieldKey)}
150
+ error={Boolean(error)}
151
+ helperText={error}
152
+ onChange={(event) =>
153
+ onChange({
154
+ ...value,
155
+ // The CPF keeps its progressive mask; everything else is taken as typed.
156
+ [fieldKey]: fieldKey === "taxId" ? formatCpf(event.target.value) : event.target.value,
157
+ })
158
+ }
159
+ data-testid={spec.testId}
160
+ />
81
161
  );
82
162
  }
83
163
 
84
- /** The optional receipt fields (name / e-mail / phone). */
85
- function OptionalContactFields({
164
+ /**
165
+ * Buyer contact at checkout, with WHICH fields are required decided by the
166
+ * store's provider chain (FUT-595) rather than hard-coded.
167
+ *
168
+ * Passing no `fields` is the pre-FUT-595 form, verbatim: CPF required, name /
169
+ * e-mail / telefone optional and used only for the receipt. That is also what
170
+ * `buyerFieldsFor` produces for a chain that declares nothing, so a caller
171
+ * cannot accidentally land on "ask nothing".
172
+ */
173
+ export function BuyerInfoForm({
86
174
  value,
87
175
  onChange,
88
- errors,
176
+ fieldError,
177
+ fields,
89
178
  }: {
90
179
  value: BuyerInfo;
91
180
  onChange: (buyer: BuyerInfo) => void;
92
- errors: BuyerFieldErrors;
181
+ fieldError?: FieldError;
182
+ /** The chain's resolved declaration; absent ⇒ CPF-required (never "nothing"). */
183
+ fields?: readonly CheckoutCustomerField[];
93
184
  }): JSX.Element {
94
- const { Input } = useCheckoutComponents();
185
+ const { Text } = useCheckoutComponents();
186
+ const { shown, required } = resolveShape(fields);
187
+ const errors = deriveErrors(value, fieldError ?? null, required);
188
+
95
189
  return (
96
- <>
97
- <Input
98
- label="Nome"
99
- type="text"
100
- variant="outlined"
101
- size="md"
102
- fullWidth
103
- autoComplete="name"
104
- value={value.name ?? ""}
105
- error={Boolean(errors.name)}
106
- helperText={errors.name}
107
- onChange={(event) => onChange({ ...value, name: event.target.value })}
108
- data-testid="buyer-name"
109
- />
110
- <Input
111
- label="E-mail"
112
- type="email"
113
- variant="outlined"
114
- size="md"
115
- fullWidth
116
- autoComplete="email"
117
- value={value.email ?? ""}
118
- error={Boolean(errors.email)}
119
- helperText={errors.email}
120
- onChange={(event) => onChange({ ...value, email: event.target.value })}
121
- data-testid="buyer-email"
122
- />
123
- <Input
124
- label="Telefone"
125
- type="tel"
126
- variant="outlined"
127
- size="md"
128
- fullWidth
129
- autoComplete="tel"
130
- value={value.phone ?? ""}
131
- error={Boolean(errors.phone)}
132
- helperText={errors.phone}
133
- onChange={(event) => onChange({ ...value, phone: event.target.value })}
134
- data-testid="buyer-phone"
135
- />
136
- </>
190
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
191
+ <Text variant="caption" size="xs" color="secondary" as="p">
192
+ {instructionFor(required)}
193
+ </Text>
194
+
195
+ {shown.map((key) => (
196
+ <BuyerInput
197
+ key={key}
198
+ fieldKey={key}
199
+ value={value}
200
+ required={required.has(key)}
201
+ error={errors[INPUTS[key].errorKey]}
202
+ onChange={onChange}
203
+ />
204
+ ))}
205
+ </Box>
137
206
  );
138
207
  }
@@ -11,6 +11,17 @@ import { refreshCardPublicKey } from "./client";
11
11
  import type { CardChainLink } from "./method-capability";
12
12
  import type { SavedCardMeta } from "./types";
13
13
 
14
+ /**
15
+ * The order-scoped key refresh, as a parameter (FUT-741).
16
+ *
17
+ * The self-heal is a call to OUR OWN mount, so it has to go through whichever
18
+ * transport the surrounding checkout was bound to. Defaulted to the unbound
19
+ * module function, which is exactly what it always called.
20
+ */
21
+ export type RefreshBrowserKey = (input: {
22
+ orderId: string;
23
+ }) => Promise<Result<{ publicKey: string | null }>>;
24
+
14
25
  /**
15
26
  * TOKENIZATION for the buyer's card — one instrument per provider the charge
16
27
  * may reach (FUT-563).
@@ -39,12 +50,13 @@ async function tokenizeNewCard(
39
50
  config: CardTokenizationConfig,
40
51
  orderId: string,
41
52
  onKeyRefreshed: (key: string) => void,
53
+ refreshKey: RefreshBrowserKey,
42
54
  ): Promise<Result<CardToken>> {
43
55
  const first = await tokenizeForCheckout(card, config);
44
56
  if (first.ok || !config.publicKey) return first;
45
57
  if (config.provider === null || tokenizerFor(config.provider) !== "pagbank-sdk") return first;
46
58
 
47
- const refreshed = await refreshCardPublicKey({ orderId });
59
+ const refreshed = await refreshKey({ orderId });
48
60
  if (refreshed.ok && refreshed.data.publicKey && refreshed.data.publicKey !== config.publicKey) {
49
61
  onKeyRefreshed(refreshed.data.publicKey);
50
62
  return tokenizeForCheckout(card, { ...config, publicKey: refreshed.data.publicKey });
@@ -166,6 +178,46 @@ function mintingConfig(
166
178
  return mintable.some((link) => link.provider === config.provider) ? config : mintable[0]!;
167
179
  }
168
180
 
181
+ /**
182
+ * Mint for the whole chain at once, and say which entry the bare token is from.
183
+ *
184
+ * Head and tail mint CONCURRENTLY. Sequentially, one unreachable backup
185
+ * acquirer held a healthy head's charge for as long as the network stack
186
+ * allowed — the failover feature blocking on the provider it exists to fall
187
+ * back to. The head keeps no deadline: it is the provider being paid, and its
188
+ * self-heal is a second round trip of our own. That self-heal also stays
189
+ * PagBank-only — `tokenizeNewCard` gates on the scheme — so a chain headed
190
+ * elsewhere cannot ask for somebody else's key.
191
+ */
192
+ async function mintEveryEntry(input: {
193
+ card: CardDetails;
194
+ entries: readonly CardChainLink[];
195
+ config: CardTokenizationConfig;
196
+ orderId: string;
197
+ onKeyRefreshed: (key: string) => void;
198
+ refreshKey: RefreshBrowserKey;
199
+ timeoutMs: number;
200
+ }): Promise<{ headToken: Result<CardToken>; minted: Record<string, CardToken> }> {
201
+ const head = mintingConfig(input.config, input.entries);
202
+ const rest = input.entries.filter((link) => link.provider !== head.provider);
203
+ const [headToken, tail] = await Promise.all([
204
+ tokenizeNewCard(input.card, head, input.orderId, input.onKeyRefreshed, input.refreshKey),
205
+ mintChainInstruments(input.card, rest, input.timeoutMs),
206
+ ]);
207
+ // A failure in the tail is not fatal: that provider is simply one the walk
208
+ // will skip.
209
+ const minted = { ...tail };
210
+ if (headToken.ok && head.provider) minted[head.provider] = headToken.data;
211
+ return { headToken, minted };
212
+ }
213
+
214
+ /** The instruments, reduced to the provider→token map the charge body carries. */
215
+ function tokenMapOf(minted: Record<string, CardToken>): Record<string, string> {
216
+ return Object.fromEntries(
217
+ Object.entries(minted).map(([provider, instrument]) => [provider, instrument.token]),
218
+ );
219
+ }
220
+
169
221
  /** The charge token for a new card (tokenize + self-heal), plus optional save-meta. */
170
222
  export async function resolveNewCardToken(
171
223
  card: CardDetails,
@@ -176,36 +228,28 @@ export async function resolveNewCardToken(
176
228
  chain: readonly CardChainLink[],
177
229
  /** Per-entry mint deadline. Overridable so tests need not wait it out. */
178
230
  timeoutMs: number = MINT_TIMEOUT_MS,
231
+ /** The bound key refresh (FUT-741); defaults to the unbound module call. */
232
+ refreshKey: RefreshBrowserKey = refreshCardPublicKey,
179
233
  ): Promise<Result<CardInstruments>> {
180
234
  // No chain served (an older host, or a fetch blip): the active provider
181
235
  // alone, exactly the pre-FUT-563 behaviour.
182
236
  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;
237
+ const { headToken, minted } = await mintEveryEntry({
238
+ card,
239
+ entries,
240
+ config,
241
+ orderId,
242
+ onKeyRefreshed,
243
+ refreshKey,
244
+ timeoutMs,
245
+ });
200
246
 
201
247
  // Refused only when NO entry could be minted for. While one still can, the
202
248
  // charge goes out and the entries we hold nothing for are skipped by name.
203
249
  const anyMinted = Object.values(minted);
204
250
  if (!headToken.ok && anyMinted.length === 0) return headToken;
205
251
  const primary = headToken.ok ? headToken.data : anyMinted[0]!;
206
- const tokensByProvider = Object.fromEntries(
207
- Object.entries(minted).map(([provider, instrument]) => [provider, instrument.token]),
208
- );
252
+ const tokensByProvider = tokenMapOf(minted);
209
253
  return ok({
210
254
  token: primary.token,
211
255
  // Sent whenever the WALK has more than one provider to reach — counted on
@@ -1,6 +1,7 @@
1
1
  import { Box } from "@mui/material";
2
- import type { JSX, ReactNode } from "react";
2
+ import { useMemo, type JSX, type ReactNode } from "react";
3
3
 
4
+ import { buyerFieldsFor } from "./buyer-fields";
4
5
  import { DadosStep, EmptyCart, PaymentStep } from "./checkout-steps";
5
6
  import { ArrowBackIcon } from "./icons";
6
7
  import { PaymentStatus } from "./payment-status";
@@ -118,7 +119,12 @@ function ProgressHeader({ step, completed }: { step: string; completed: Set<stri
118
119
  */
119
120
  function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Element {
120
121
  const { cart, defaultBuyer, comanda, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, ...ports } = props;
121
- const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile);
122
+ // Resolved for NO method on purpose (FUT-595): the Dados step opens before
123
+ // the picker, and the form is filled once — so it asks for the union of what
124
+ // any chain member may need rather than re-opening after the choice. A chain
125
+ // that declares nothing degrades to CPF-required, never to "ask nothing".
126
+ const buyerFields = useMemo(() => buyerFieldsFor(providerConfig?.chain, null), [providerConfig]);
127
+ const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields);
122
128
 
123
129
  // A comanda settlement pays already-sent kitchen items — the cart is
124
130
  // legitimately empty here, so the empty-cart guard only applies to cart mode.
@@ -147,6 +153,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
147
153
  errorField={c.errorField}
148
154
  onContinue={c.goToPayment}
149
155
  cartTotals={cart}
156
+ buyerFields={buyerFields}
150
157
  discountLines={cart.discountLines}
151
158
  totalOverride={comandaTotalOverride(comanda)}
152
159
  />