@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.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The checkout's HTTP transport, as a bound client (FUT-741).
3
+ *
4
+ * Everything `client.ts` used to do with a hard-coded prefix and the ambient
5
+ * `fetch` now lives here behind {@link createCheckoutClient}, so the same five
6
+ * calls can be pointed at a different mount, carry a host's auth headers, or —
7
+ * the reason this exists — be driven through an injected `fetch` that routes
8
+ * straight into a real `createPaymentFlowsBE` mount. A story or a harness page
9
+ * can then exercise the WIRE rather than a mock of our own client, which is the
10
+ * only place the FUT-740 review found its three criticals.
11
+ *
12
+ * `baseUrl` defaults to `/api/checkout` VERBATIM — not normalized, not
13
+ * re-derived. The published client already posts those exact paths, and both
14
+ * pinned wire suites drive them; a prefix that is "cleaned up" here breaks the
15
+ * shipped contract in the same release that introduces the factory.
16
+ */
17
+
18
+ import type { SavedCard } from "../../card";
19
+ import { err, ok, type Result } from "../../result";
20
+
21
+ import type {
22
+ ChargeCardInput,
23
+ ChargeOutcome,
24
+ CheckoutProviderConfig,
25
+ OrderStatus,
26
+ } from "./types";
27
+
28
+ /**
29
+ * The prefix every shipped buyer checkout posts to today. Exported so a host
30
+ * (or a test) can state it rather than re-type it, and so a change to it is a
31
+ * change to one named constant with this comment attached.
32
+ */
33
+ export const DEFAULT_CHECKOUT_BASE_URL = "/api/checkout";
34
+
35
+ /** Where the `createPaymentFlowsBE` mount lives, and how to reach it. */
36
+ export interface CheckoutTransport {
37
+ /** Prefix for `/config`, `/status`, `/charge`, `/cards`, `/refresh-key`. */
38
+ baseUrl?: string;
39
+ /**
40
+ * The `fetch` to call. Omitted ⇒ the ambient one, resolved PER CALL so a
41
+ * suite that stubs the global after the client was built still sees its stub.
42
+ */
43
+ fetchImpl?: typeof fetch;
44
+ /** Extra headers per request (a bearer token, a tenant header). */
45
+ headers?: () => HeadersInit | Promise<HeadersInit>;
46
+ }
47
+
48
+ /** The five calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
49
+ export interface CheckoutClient {
50
+ getConfig(tenantSlug: string): Promise<Result<CheckoutProviderConfig>>;
51
+ getStatus(ref: string): Promise<Result<OrderStatus>>;
52
+ charge(input: ChargeCardInput): Promise<Result<ChargeOutcome>>;
53
+ listInstruments(tenantSlug?: string): Promise<SavedCard[]>;
54
+ refreshBrowserKey(input: { orderId: string }): Promise<Result<{ publicKey: string | null }>>;
55
+ }
56
+
57
+ /** Envelope the API routes return: `{ data }` on success, `{ error }` on failure. */
58
+ interface ApiEnvelope<T> {
59
+ data?: T;
60
+ error?: string;
61
+ /**
62
+ * Stable machine code for the failure (`checkoutErrorResponse` always sends
63
+ * one). Carried through so a surface can PRESENT a refusal for what it is —
64
+ * an unresolved charge is not a decline, and rendering it under "não foi
65
+ * possível pagar" with a live pay button invites the second payment its own
66
+ * text forbids.
67
+ */
68
+ code?: string;
69
+ }
70
+
71
+ /**
72
+ * A non-2xx envelope as a {@link Result} failure, carrying its machine CODE.
73
+ * The message is what the buyer reads; the code is what a surface uses to
74
+ * decide how to PRESENT it, which a message cannot be parsed for.
75
+ */
76
+ function refused<T>(json: ApiEnvelope<T> | null): Result<T> {
77
+ return err(json?.error ?? "Não foi possível concluir a operação. Tente novamente.", json?.code);
78
+ }
79
+
80
+ /**
81
+ * What a hosted checkout appended to the return URL when it sent the buyer
82
+ * back, or undefined.
83
+ *
84
+ * InfinitePay's `payment_check` refuses to confirm without BOTH the
85
+ * `transaction_nsu` and the invoice `slug`, and neither exists until somebody
86
+ * has actually paid — they arrive here, on the redirect. Left in the URL: the
87
+ * server treats them as hints, so re-sending them on later polls is harmless.
88
+ *
89
+ * Note the buyer has to press "Continuar" on the provider's receipt for this
90
+ * redirect to happen at all, which is exactly why it is a hint and never the
91
+ * mechanism: the webhook, and the server-side reconciliation behind it, are
92
+ * what must work when they simply close the tab.
93
+ */
94
+ function returnedSettlement(): Record<string, string> {
95
+ if (typeof window === "undefined") return {};
96
+ const params = new URLSearchParams(window.location.search);
97
+ // BOTH. Measured against the live API: handle + order_nsu +
98
+ // transaction_nsu + slug answers {"success":true,"paid":true,…}, and the
99
+ // same call missing EITHER answers {"success":false}. Neither exists before
100
+ // the payment, and the slug is not in the link-creation response — the
101
+ // redirect and the webhook are the only places both appear together.
102
+ const transactionNsu = params.get("transaction_nsu") ?? params.get("transaction_id") ?? "";
103
+ const slug = params.get("slug") ?? "";
104
+ return {
105
+ ...(transactionNsu ? { transactionNsu } : {}),
106
+ ...(slug ? { slug } : {}),
107
+ };
108
+ }
109
+
110
+ /** The ambient `fetch`, wrapped so it is never invoked detached from its global. */
111
+ function ambientFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
112
+ return globalThis.fetch(input, init);
113
+ }
114
+
115
+ /**
116
+ * The five checkout calls, bound to one transport.
117
+ *
118
+ * Passing no transport reproduces exactly what the free functions in
119
+ * `client.ts` have always done: `/api/checkout/**` on the ambient `fetch`.
120
+ */
121
+ export function createCheckoutClient(transport: CheckoutTransport = {}): CheckoutClient {
122
+ const baseUrl = transport.baseUrl ?? DEFAULT_CHECKOUT_BASE_URL;
123
+
124
+ /** Call a checkout route and normalize the response into a {@link Result}. */
125
+ async function call<T>(route: string, init?: RequestInit): Promise<Result<T>> {
126
+ // Resolved here, not captured at build time: a suite (or a story) that
127
+ // replaces the global afterwards must still be the one that answers.
128
+ const doFetch = transport.fetchImpl ?? ambientFetch;
129
+ try {
130
+ const extra = transport.headers ? await transport.headers() : undefined;
131
+ const res = await doFetch(`${baseUrl}${route}`, {
132
+ ...init,
133
+ headers: { "Content-Type": "application/json", ...extra, ...init?.headers },
134
+ });
135
+ const json = (await res.json().catch(() => null)) as ApiEnvelope<T> | null;
136
+ if (!res.ok) return refused(json);
137
+ if (!json || json.data === undefined) {
138
+ return err(json?.error ?? "Resposta inválida do servidor.");
139
+ }
140
+ return ok(json.data);
141
+ } catch {
142
+ return err("Não foi possível conectar. Verifique sua conexão e tente novamente.");
143
+ }
144
+ }
145
+
146
+ return {
147
+ getConfig: (tenantSlug) =>
148
+ call<CheckoutProviderConfig>(`/config?tenantSlug=${encodeURIComponent(tenantSlug)}`, {
149
+ method: "GET",
150
+ }),
151
+
152
+ getStatus: (ref) =>
153
+ call<OrderStatus>(
154
+ `/status?${new URLSearchParams({ orderId: ref, ...returnedSettlement() }).toString()}`,
155
+ { method: "GET" },
156
+ ),
157
+
158
+ charge: (input) =>
159
+ call<ChargeOutcome>("/charge", {
160
+ method: "POST",
161
+ // The FLAT body the shipped client has always sent. Pinned from both
162
+ // ends by `charge-wire.contract.test.ts`; nothing here may re-nest it.
163
+ body: JSON.stringify({
164
+ orderId: input.orderId,
165
+ token: input.token,
166
+ // One instrument per provider (FUT-563) — the server hands each
167
+ // provider in the chain its own, which is what lets a card charge
168
+ // fail over.
169
+ ...(input.tokensByProvider ? { tokensByProvider: input.tokensByProvider } : {}),
170
+ saveCard: input.saveCard,
171
+ cardMeta: input.cardMeta,
172
+ taxId: input.taxId,
173
+ }),
174
+ }),
175
+
176
+ listInstruments: async (tenantSlug) => {
177
+ // Scoped to the store when known (FUT-697): only cards the store's ACTIVE
178
+ // provider can actually charge come back — a PagBank-vaulted card is not
179
+ // offered for a Stone charge it would fail (or misroute).
180
+ const query = tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : "";
181
+ const result = await call<SavedCard[]>(`/cards${query}`, { method: "GET" });
182
+ return result.ok ? result.data : [];
183
+ },
184
+
185
+ refreshBrowserKey: (input) =>
186
+ call<{ publicKey: string | null }>("/refresh-key", {
187
+ method: "POST",
188
+ body: JSON.stringify({ orderId: input.orderId }),
189
+ }),
190
+ };
191
+ }
@@ -198,6 +198,17 @@ export interface ChargeCardInput {
198
198
  taxId?: string;
199
199
  }
200
200
 
201
+ /** A buyer field a provider asks for, as `/config` publishes it (FUT-595). */
202
+ export interface CheckoutCustomerField {
203
+ key: "name" | "email" | "taxId" | "phone";
204
+ /** Which validation rule applies. `MOBILE` is the strictly narrower `PHONE`. */
205
+ type: "NAME" | "EMAIL" | "PHONE" | "MOBILE" | "CPF";
206
+ /** The provider refuses (or its own page stalls) without this field. */
207
+ required: boolean;
208
+ /** Methods this spec applies to; omitted means every method the adapter takes. */
209
+ methods?: ("PIX" | "CARD" | "BOLETO")[];
210
+ }
211
+
201
212
  /**
202
213
  * ONE enabled provider of the store's chain (FUT-563), as the buyer's config
203
214
  * answers it. The same protocol facts as the head, stated per provider —
@@ -209,6 +220,22 @@ export interface CheckoutChainLink {
209
220
  publicKey: string | null;
210
221
  mockTokenization: boolean;
211
222
  methods: ("PIX" | "CARD" | "BOLETO")[];
223
+ /**
224
+ * What this provider needs to know about the buyer (FUT-595).
225
+ *
226
+ * `GET /api/checkout/config` has published this since FUT-740; this mirror
227
+ * type simply dropped it, so the buyer form could not be honest about what
228
+ * the chain requires and a missing field was only discoverable as a 400 AFTER
229
+ * the buyer had finished filling it in — the third FUT-740 critical, one
230
+ * layer up.
231
+ *
232
+ * Optional, and the DEGRADE DIRECTION IS LOAD-BEARING: absent (an older host,
233
+ * a hand-written config) must mean today's behaviour — CPF required — never
234
+ * "this chain asks for nothing". Asking for nothing produces a form the buyer
235
+ * completes and a charge the server then refuses for a document they were
236
+ * never shown a field for.
237
+ */
238
+ customerSchema?: CheckoutCustomerField[];
212
239
  }
213
240
 
214
241
  /**
@@ -145,7 +145,18 @@ const CheckoutComponentsContext = createContext<CheckoutComponents>(defaultCheck
145
145
  /**
146
146
  * Fills the checkout's component slots for everything rendered beneath it.
147
147
  * Partial on purpose: a host overrides only the slots its design system
148
- * covers and inherits the raw-MUI default for the rest.
148
+ * covers and inherits the rest.
149
+ *
150
+ * Inherits from the NEAREST provider above, not from the raw-MUI defaults.
151
+ * That distinction is the whole contract once these nest — and they do nest:
152
+ * `createPaymentFlows` fills the slots once at the factory, and the
153
+ * `CheckoutFlow` inside its mount opens a provider of its own. Merging over
154
+ * the defaults there re-imposed raw MUI on a host that had already stated its
155
+ * design system, silently, one level in. Merging over the inherited set makes
156
+ * a nested provider ADD to what is above it, which is what "partial" has to
157
+ * mean for the surface to agree with itself. The context's own default is
158
+ * still `defaultCheckoutComponents`, so the outermost provider is unchanged:
159
+ * an unfilled slot is raw MUI exactly as before.
149
160
  */
150
161
  export function CheckoutComponentsProvider({
151
162
  components,
@@ -154,9 +165,10 @@ export function CheckoutComponentsProvider({
154
165
  components?: Partial<CheckoutComponents>;
155
166
  children: ReactNode;
156
167
  }): JSX.Element {
168
+ const inherited = useCheckoutComponents();
157
169
  const value = useMemo<CheckoutComponents>(
158
- () => ({ ...defaultCheckoutComponents, ...components }),
159
- [components],
170
+ () => ({ ...inherited, ...components }),
171
+ [inherited, components],
160
172
  );
161
173
  return (
162
174
  <CheckoutComponentsContext.Provider value={value}>
@@ -17,13 +17,14 @@ import {
17
17
  } from "../../card";
18
18
  import { ok, type Result } from "../../result";
19
19
 
20
- import { resolveNewCardToken, type CardInstruments } from "./card-instruments";
21
20
  import {
22
- chargeCard,
23
- listSavedCards,
24
- refreshCardPublicKey,
25
- } from "./client";
21
+ resolveNewCardToken,
22
+ type CardInstruments,
23
+ type RefreshBrowserKey,
24
+ } from "./card-instruments";
25
+ import { useCheckoutClientApi } from "./client-context";
26
26
  import { rememberHostedOrder } from "./hosted-return";
27
+ import { useCheckoutNavigate, type CheckoutNavigate } from "./navigate-context";
27
28
  import type { CardChainLink } from "./method-capability";
28
29
  import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
29
30
  import { usePaymentPolling } from "./use-payment-polling";
@@ -43,10 +44,11 @@ function useSavedCards(tenantSlug: string | undefined): {
43
44
  } {
44
45
  const [savedCards, setSavedCards] = useState<SavedCard[]>([]);
45
46
  const [selection, setSelection] = useState<string>(NEW_CARD);
47
+ const client = useCheckoutClientApi();
46
48
 
47
49
  useEffect(() => {
48
50
  let active = true;
49
- void listSavedCards(tenantSlug).then((cards) => {
51
+ void client.listInstruments(tenantSlug).then((cards) => {
50
52
  if (!active) return;
51
53
  setSavedCards(cards);
52
54
  const first = cards[0];
@@ -55,7 +57,7 @@ function useSavedCards(tenantSlug: string | undefined): {
55
57
  return () => {
56
58
  active = false;
57
59
  };
58
- }, [tenantSlug]);
60
+ }, [tenantSlug, client]);
59
61
 
60
62
  return { savedCards, selection, setSelection };
61
63
  }
@@ -76,19 +78,20 @@ function useCardPublicKey(
76
78
  setPublicKey: (key: string) => void;
77
79
  } {
78
80
  const [publicKey, setPublicKey] = useState<string | null>(config.publicKey);
81
+ const client = useCheckoutClientApi();
79
82
  const refreshable =
80
83
  config.provider !== null && tokenizerFor(config.provider) === "pagbank-sdk";
81
84
 
82
85
  useEffect(() => {
83
86
  if (config.publicKey !== null || !refreshable) return undefined;
84
87
  let active = true;
85
- void refreshCardPublicKey({ orderId }).then((result) => {
88
+ void client.refreshBrowserKey({ orderId }).then((result) => {
86
89
  if (active && result.ok && result.data.publicKey) setPublicKey(result.data.publicKey);
87
90
  });
88
91
  return () => {
89
92
  active = false;
90
93
  };
91
- }, [orderId, config.publicKey, refreshable]);
94
+ }, [orderId, config.publicKey, refreshable, client]);
92
95
 
93
96
  return { publicKey, setPublicKey };
94
97
  }
@@ -151,9 +154,16 @@ const CARD_AWAITING_POLL_CAP = 36;
151
154
  * redirect provider's link takes (FUT-556): the return lands back on this
152
155
  * checkout route, where the hosted-resume machinery polls the parked order.
153
156
  */
154
- function handOverToChallenge(order: CheckoutOrder, url: string): void {
157
+ function handOverToChallenge(
158
+ order: CheckoutOrder,
159
+ url: string,
160
+ navigate: CheckoutNavigate,
161
+ ): void {
162
+ // PARK FIRST. The navigation may not come back to a live SPA at all, and a
163
+ // return trip that finds nothing parked lands the buyer on a blank
164
+ // confirmation after they have paid.
155
165
  rememberHostedOrder(order);
156
- window.location.assign(url);
166
+ navigate(url);
157
167
  }
158
168
 
159
169
  /** The buyer's card is invalid — block the submit and show which field. */
@@ -178,6 +188,8 @@ async function resolveInstruments(input: {
178
188
  config: CardTokenizationConfig;
179
189
  onKeyRefreshed: (key: string) => void;
180
190
  providerChain: readonly CardChainLink[];
191
+ /** The bound key-refresh call (FUT-741) — the self-heal must hit OUR mount. */
192
+ refreshKey: RefreshBrowserKey;
181
193
  }): Promise<Result<CardInstruments>> {
182
194
  const { form } = input;
183
195
  if (!form.usingNewCard) return ok({ token: form.selection });
@@ -188,6 +200,8 @@ async function resolveInstruments(input: {
188
200
  input.onKeyRefreshed,
189
201
  form.saveCard,
190
202
  input.providerChain,
203
+ undefined,
204
+ input.refreshKey,
191
205
  );
192
206
  }
193
207
 
@@ -212,6 +226,8 @@ function useCardSubmit(
212
226
  // The active key: resolved by the checkout config, overridden by the
213
227
  // rotated-key self-heal for the rest of the session.
214
228
  const { publicKey, setPublicKey } = useCardPublicKey(order.orderId, providerConfig);
229
+ const client = useCheckoutClientApi();
230
+ const navigate = useCheckoutNavigate();
215
231
 
216
232
  const { status, error: pollError, timedOut: pollTimedOut } = usePaymentPolling(order.orderId, {
217
233
  enabled: submitted,
@@ -235,6 +251,7 @@ function useCardSubmit(
235
251
  config: { ...providerConfig, publicKey },
236
252
  onKeyRefreshed: setPublicKey,
237
253
  providerChain,
254
+ refreshKey: client.refreshBrowserKey,
238
255
  });
239
256
  if (!resolved.ok) {
240
257
  setError(resolved.error);
@@ -242,7 +259,7 @@ function useCardSubmit(
242
259
  return;
243
260
  }
244
261
 
245
- const charged = await chargeCard({
262
+ const charged = await client.charge({
246
263
  orderId: order.orderId,
247
264
  token: resolved.data.token,
248
265
  tokensByProvider: resolved.data.tokensByProvider,
@@ -261,7 +278,7 @@ function useCardSubmit(
261
278
  // 3-D Secure (FUT-698): the buyer must finish on the provider's page.
262
279
  // `submitting` stays true on purpose — the tab is navigating away.
263
280
  if (charged.data.hostedCheckoutUrl) {
264
- return handOverToChallenge(order, charged.data.hostedCheckoutUrl);
281
+ return handOverToChallenge(order, charged.data.hostedCheckoutUrl, navigate);
265
282
  }
266
283
  // A business outcome (e.g. declined → FAILED) shows the status screen;
267
284
  // an accepted charge begins polling for the async confirmation.
@@ -1,14 +1,16 @@
1
1
  import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
2
2
 
3
- import { validateCpf } from "../../card";
3
+ import { buyerFormComplete } from "./buyer-info-form";
4
4
 
5
5
  import { rememberHostedOrder, takeHostedOrder } from "./hosted-return";
6
+ import { useCheckoutNavigate, type CheckoutNavigate } from "./navigate-context";
6
7
  import type {
7
8
  BuyerContact,
8
9
  BuyerField,
9
10
  BuyerInfo,
10
11
  CheckoutOrder,
11
12
  CreateOrderRequest,
13
+ CheckoutCustomerField,
12
14
  CreateOrderResult,
13
15
  OrderStatus,
14
16
  PaymentMethod,
@@ -19,6 +21,11 @@ type Step = "dados" | "payment" | "status";
19
21
 
20
22
  const STEP_ORDER: Step[] = ["dados", "payment", "status"];
21
23
 
24
+ /** The pre-FUT-595 demand, and the safe reading of a chain that declared none. */
25
+ const CPF_ONLY: readonly CheckoutCustomerField[] = [
26
+ { key: "taxId", type: "CPF", required: true },
27
+ ];
28
+
22
29
  /**
23
30
  * Everything the flow needs FROM its host, as explicit ports (FUT-564). The
24
31
  * package owns the payment surface; the host owns the cart, the catalog,
@@ -81,19 +88,49 @@ function useCheckoutNav(
81
88
  return { back, editBuyer: taxIdOnFile ? openDados : undefined };
82
89
  }
83
90
 
91
+ /** The message shown when a declared field is missing or malformed. */
92
+ const FIELD_COMPLAINT: Record<string, string> = {
93
+ taxId: "CPF inválido.",
94
+ name: "Informe seu nome.",
95
+ email: "E-mail inválido.",
96
+ phone: "Telefone inválido.",
97
+ };
98
+
99
+ /** Which input to highlight for a declared key. */
100
+ const FIELD_INPUT: Record<string, BuyerField> = {
101
+ taxId: "cpf",
102
+ name: "name",
103
+ email: "email",
104
+ phone: "phone",
105
+ };
106
+
84
107
  /**
85
- * What the "Continuar" gate objects to about the CPF, or undefined to advance.
108
+ * What the "Continuar" gate objects to, or undefined to advance.
86
109
  *
87
- * A blank field is only an error when the store has NO CPF for this buyer. With
110
+ * The fields come from the chain's own declaration (FUT-595) absent, they
111
+ * degrade to CPF-required, which is exactly what this gate has always demanded.
112
+ *
113
+ * A blank CPF is only an error when the store has NO CPF for this buyer. With
88
114
  * one on file the field starts empty by design (the client is never sent the
89
115
  * saved CPF), so demanding one here trapped a returning buyer who opened Dados
90
116
  * through "Alterar" and changed their mind: they could not reach Pagamento
91
117
  * again, and back only led out to the menu. Leaving it blank means "charge me
92
118
  * as before", which is exactly what the server's `resolveBuyerTaxId` does.
93
119
  */
94
- function cpfGateError(typed: string, taxIdOnFile: boolean): string | undefined {
95
- if (!typed && taxIdOnFile) return undefined;
96
- return validateCpf(typed);
120
+ function buyerGateError(
121
+ buyer: BuyerInfo,
122
+ fields: readonly CheckoutCustomerField[],
123
+ taxIdOnFile: boolean,
124
+ ): { message: string; field: BuyerField } | undefined {
125
+ const effective = taxIdOnFile
126
+ ? fields.filter((field) => !(field.key === "taxId" && !buyer.taxId?.trim()))
127
+ : fields;
128
+ const offending = buyerFormComplete(buyer, effective);
129
+ if (!offending) return undefined;
130
+ return {
131
+ message: FIELD_COMPLAINT[offending.key] ?? "Campo obrigatório.",
132
+ field: FIELD_INPUT[offending.key] ?? "cpf",
133
+ };
97
134
  }
98
135
 
99
136
  /**
@@ -119,10 +156,13 @@ function initialStep(resuming: boolean, taxIdOnFile: boolean): Step {
119
156
  *
120
157
  * @returns true when the buyer is on their way and the caller must stop.
121
158
  */
122
- function handOverToProvider(order: CheckoutOrder): boolean {
159
+ function handOverToProvider(order: CheckoutOrder, navigate: CheckoutNavigate): boolean {
123
160
  if (!order.hostedCheckoutUrl) return false;
161
+ // PARK FIRST, navigate second. The order is the only thing the return trip
162
+ // has to rehydrate from, and the navigation may tear this SPA down before
163
+ // any later write lands.
124
164
  rememberHostedOrder(order);
125
- window.location.assign(order.hostedCheckoutUrl);
165
+ navigate(order.hostedCheckoutUrl);
126
166
  return true;
127
167
  }
128
168
 
@@ -197,20 +237,24 @@ function useCreateFailure() {
197
237
  * being saved.
198
238
  * - `startPayment` raises the order (PIX → QR, CARD → chargeable); `payWithEmail`
199
239
  * re-raises with a corrected e-mail for the merchant-email rejection.
240
+ *
241
+ * @param taxIdOnFile The buyer already has a CPF saved (FUT-465) ⇒ the Dados
242
+ * step has nothing left to ask, so checkout opens on Pagamento and `back`
243
+ * goes to the menu rather than to a form the buyer never saw. The CPF itself
244
+ * is never in the client's hands — the server reads it from the encrypted
245
+ * profile when the charge is raised.
246
+ * @param buyerFields What the store's chain declares it needs from the buyer
247
+ * (FUT-595). Absent ⇒ CPF-required, which is what this gate demanded before
248
+ * there was a declaration to read — never "ask nothing".
200
249
  */
201
250
  export function useCheckoutController(
202
251
  ports: CheckoutHostPorts,
203
252
  defaultBuyer?: BuyerInfo,
204
- /**
205
- * The buyer already has a CPF saved (FUT-465) ⇒ the Dados step has nothing
206
- * left to ask, so checkout opens on Pagamento and `back` goes to the menu
207
- * rather than to a form the buyer never saw. The CPF itself is never in the
208
- * client's hands — the server reads it from the encrypted profile when the
209
- * charge is raised.
210
- */
211
253
  taxIdOnFile = false,
254
+ buyerFields: readonly CheckoutCustomerField[] = CPF_ONLY,
212
255
  ) {
213
256
  const { createOrder, saveBuyerContact, onExitToMenu, onPaid } = ports;
257
+ const navigate = useCheckoutNavigate();
214
258
  const resume = useHostedResume();
215
259
  const [step, setStep] = useState<Step>(initialStep(Boolean(resume.order), taxIdOnFile));
216
260
  // No method pre-selected: the Pagamento step shows just the picker until the
@@ -232,8 +276,8 @@ export function useCheckoutController(
232
276
  }, [clearError]);
233
277
  const goToPayment = useCallback(() => {
234
278
  clearError();
235
- const cpfError = cpfGateError(buyer.taxId?.trim() ?? "", taxIdOnFile);
236
- if (cpfError) { failure.fail({ message: cpfError, field: "cpf" }); return; }
279
+ const complaint = buyerGateError(buyer, buyerFields, taxIdOnFile);
280
+ if (complaint) { failure.fail(complaint); return; }
237
281
  // Persist the buyer's details HERE, on "Continuar" — not when a payment is
238
282
  // raised. Everything after this step can fail (no provider configured, a
239
283
  // declined card, an abandoned PIX, a closed tab) and the details must
@@ -245,17 +289,17 @@ export function useCheckoutController(
245
289
  saveBuyerContact?.({ name: buyer.name, phone: buyer.phone, taxId: buyer.taxId });
246
290
  }
247
291
  setStep("payment");
248
- }, [buyer.name, buyer.phone, buyer.taxId, saveProfile, taxIdOnFile, clearError, saveBuyerContact]);
292
+ }, [buyer, buyerFields, saveProfile, taxIdOnFile, clearError, saveBuyerContact]);
249
293
  const startPayment = useCallback(async (chosen: PaymentMethod, override?: BuyerInfo) => {
250
294
  clearError();
251
295
  setCreating(true);
252
296
  const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
253
297
  setCreating(false);
254
298
  if (!result.ok) { failure.fail(result.error); return; }
255
- if (handOverToProvider(result.data)) return;
299
+ if (handOverToProvider(result.data, navigate)) return;
256
300
  setOrder(result.data);
257
301
  setFinalStatus(null);
258
- }, [buyer, saveProfile, createOrder, clearError]);
302
+ }, [buyer, saveProfile, createOrder, clearError, navigate]);
259
303
  const payWithEmail = useCallback((email: string) => {
260
304
  if (!method) return;
261
305
  const next = { ...buyer, email };
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from "react";
2
2
 
3
- import { pollOrderStatus } from "./client";
3
+ import { useCheckoutClientApi } from "./client-context";
4
4
  import { TERMINAL_STATUSES, type OrderStatus } from "./types";
5
5
 
6
6
  interface PollingOptions {
@@ -34,6 +34,10 @@ export function usePaymentPolling(
34
34
  const [status, setStatus] = useState<OrderStatus | null>(null);
35
35
  const [error, setError] = useState<string | null>(null);
36
36
  const [timedOut, setTimedOut] = useState(false);
37
+ // Whichever mount this tree is bound to (FUT-741). Stable per provider, so it
38
+ // belongs in the deps below rather than being read out of a ref: a checkout
39
+ // re-pointed at another mount must re-poll against THAT one.
40
+ const client = useCheckoutClientApi();
37
41
 
38
42
  useEffect(() => {
39
43
  if (!orderId || !enabled) {
@@ -48,7 +52,7 @@ export function usePaymentPolling(
48
52
  setError(null);
49
53
 
50
54
  const tick = async (): Promise<void> => {
51
- const result = await pollOrderStatus(orderId);
55
+ const result = await client.getStatus(orderId);
52
56
  if (cancelled) {
53
57
  return;
54
58
  }
@@ -87,7 +91,7 @@ export function usePaymentPolling(
87
91
  clearTimeout(timer);
88
92
  }
89
93
  };
90
- }, [orderId, intervalMs, enabled, maxHealthyPolls]);
94
+ }, [orderId, intervalMs, enabled, maxHealthyPolls, client]);
91
95
 
92
96
  return { status, error, timedOut };
93
97
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The sentences `createPaymentFlows` renders on its OWN screens (FUT-741).
3
+ *
4
+ * Scope is deliberately narrow and stated rather than implied: this covers the
5
+ * copy the FACTORY owns — the unavailable screen's two remedies, the hosted
6
+ * handover and its fallback link, the empty cart, and the buyer form's continue
7
+ * action. The screens that already carried their own product copy before this
8
+ * ticket (PIX, card, status) keep it; moving all of it here in the same change
9
+ * that introduces the factory would be a copy rewrite disguised as an API.
10
+ *
11
+ * Every default below is today's pt-BR, verbatim — a host that passes no `copy`
12
+ * reads exactly what a buyer reads now.
13
+ */
14
+
15
+ /** Every buyer-facing string the factory's own screens render. */
16
+ export interface CheckoutCopyFE {
17
+ /** No provider connected AND no host remedy: the store simply does not charge. */
18
+ unavailableTitle: string;
19
+ unavailableBody: string;
20
+ /** No provider connected but the host offers a remedy (this repo: the waiter). */
21
+ unavailableWithRemedyTitle: string;
22
+ unavailableWithRemedyBody: string;
23
+ /** The hosted handover interstitial, and the link that is its fallback. */
24
+ handoffTitle: string;
25
+ handoffBody: string;
26
+ handoffLink: string;
27
+ handoffCancel: string;
28
+ /** The return leg, while the parked order is polled to a terminal state. */
29
+ returnPending: string;
30
+ /** The return leg when nothing was parked — the buyer came back to nothing. */
31
+ returnUnknown: string;
32
+ /** Nothing to check out (cart mode only). */
33
+ emptyCartTitle: string;
34
+ emptyCartAction: string;
35
+ /** The Dados step's primary action. */
36
+ continueAction: string;
37
+ }
38
+
39
+ export const DEFAULT_CHECKOUT_COPY_FE: CheckoutCopyFE = {
40
+ unavailableTitle: "Pagamento online indisponível",
41
+ unavailableBody:
42
+ "Esta loja não recebe pagamentos pelo site. Combine o pagamento diretamente com a loja para concluir seu pedido.",
43
+ unavailableWithRemedyTitle: "Pagamento com o garçom",
44
+ unavailableWithRemedyBody:
45
+ "Esta loja não recebe pagamentos pelo site. Chame o garçom para fechar a conta na mesa.",
46
+ handoffTitle: "Você será levado ao pagamento",
47
+ handoffBody:
48
+ "Estamos abrindo a página segura do meio de pagamento. Se ela não abrir sozinha, use o link abaixo.",
49
+ handoffLink: "Abrir a página de pagamento",
50
+ handoffCancel: "Voltar",
51
+ returnPending: "Confirmando seu pagamento…",
52
+ returnUnknown:
53
+ "Não encontramos um pagamento em andamento nesta sessão. Verifique seus pedidos em instantes.",
54
+ emptyCartTitle: "Seu carrinho está vazio.",
55
+ emptyCartAction: "Ver cardápio",
56
+ continueAction: "Continuar",
57
+ };