@12-apps/payments-frontend 3.4.0 → 3.6.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.
@@ -0,0 +1,341 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import type React from 'react';
5
+
6
+ import type { RedirectActivationCopy } from './copy';
7
+ import { mintCharge } from './mint-charge';
8
+ import {
9
+ postActivation,
10
+ settleActivationPoll,
11
+ type ActivationClock,
12
+ type ActivationPendingBody,
13
+ type ActivationPollBody,
14
+ type RedirectActivationState,
15
+ } from './redirect-state';
16
+ import { clearReturnedSettlement, takeReturnedSettlement } from './returned-settlement';
17
+
18
+ /**
19
+ * The activation step for a provider whose payer pays on ITS page (FUT-463,
20
+ * moved here by FUT-763).
21
+ *
22
+ * Same proof as a card form, different protocol: a REAL link is minted through
23
+ * this store's own connection, the owner pays it on the provider's site, and
24
+ * the provider is then ASKED whether it arrived. Only that answer activates the
25
+ * store. The alternative — telling an owner to "make a real low-value order and
26
+ * confirm the money arrives" — is the dead-end instruction the step exists to
27
+ * abolish.
28
+ *
29
+ * ## What is the package's here, and what is not
30
+ *
31
+ * `renderVerification` on `PaymentProviderSettings` says the package decides
32
+ * only WHERE the step appears, never how it works. That still holds for the
33
+ * SCREEN: the panels, the states an owner reads, the sentences — all of that is
34
+ * the host's, and none of it is here.
35
+ *
36
+ * What is here is the PROTOCOL, which is not a matter of taste and is not
37
+ * something a second host should get to rediscover: pick the outstanding charge
38
+ * back up on mount, ask with the return trip's ids on every tick, keep a
39
+ * refused ATTEMPT distinct from an expired charge, keep an unreachable provider
40
+ * distinct from a refusing one, stop asking eventually. Every one of those was
41
+ * learned from a payment that went wrong, and three of them cost a real charge.
42
+ *
43
+ * ## Why the attempt is the SERVER's
44
+ *
45
+ * It used to live in React state, and that was wrong in a way that costs money.
46
+ * The flow deliberately sends the owner to another site to pay, so "left this
47
+ * page and came back" is the NORMAL path, not an edge case — and it erased the
48
+ * attempt every time. The screen then offered to generate a charge, because as
49
+ * far as it knew none existed: a second real charge on the owner's own card,
50
+ * while the first, already paid, was never asked about again.
51
+ *
52
+ * So the outstanding charge is loaded from the server on mount, and the only
53
+ * things that clear it are settlement and an explicit "give up".
54
+ */
55
+
56
+ /** How often to ask the provider whether the payment landed. */
57
+ const DEFAULT_POLL_MS = 4000;
58
+
59
+ /** Give up asking after this long, rather than polling a tab forever. */
60
+ const DEFAULT_POLL_TIMEOUT_MS = 10 * 60 * 1000;
61
+
62
+ export interface RedirectActivationOptions {
63
+ /**
64
+ * The host's verify-charge endpoint for this provider.
65
+ *
66
+ * A whole URL, not the parts of one: the route shape belongs to the host and
67
+ * the package has no business assembling it. `GET` answers the outstanding
68
+ * charge; `POST` takes `{ action: 'start' | 'poll' | 'discard' }`.
69
+ */
70
+ verifyChargeUrl: string;
71
+ /** A passing charge — the caller refreshes so the provider shows as active. */
72
+ onVerified: () => void;
73
+ /**
74
+ * The provider would not mint a link. Called instead of, not as well as,
75
+ * settling anything: no charge exists, so there is nothing to confirm — the
76
+ * caller uses it to reopen the setup step this failure implicates.
77
+ */
78
+ onCreateFailed?: () => void;
79
+ copy: RedirectActivationCopy;
80
+ /** Where the return trip's ids are parked. Defaults per the package. */
81
+ storageKey?: string;
82
+ pollMs?: number;
83
+ pollTimeoutMs?: number;
84
+ }
85
+
86
+ export interface RedirectActivation {
87
+ state: RedirectActivationState;
88
+ /**
89
+ * When the provider last answered, as epoch ms (0 before the first ask).
90
+ *
91
+ * Shown as "last checked Ns ago". A spinner alone says "something is
92
+ * happening"; this says the screen is still asking, which is the reassurance
93
+ * someone who has just paid on another site actually wants — and it is what
94
+ * stops them pressing a pay button again to be sure.
95
+ */
96
+ lastCheckedAt: number;
97
+ /** Mint the link and begin asking. */
98
+ start: () => Promise<void>;
99
+ /** Ask right now, instead of waiting for the next tick. */
100
+ checkNow: () => Promise<void>;
101
+ /** Abandon the outstanding charge and offer a fresh one. */
102
+ reset: () => void;
103
+ }
104
+
105
+ /**
106
+ * What the effects call, read through a ref rather than through their deps.
107
+ *
108
+ * NOT a style preference — it is what makes the hook safe to hand to a host.
109
+ * Every callback here changes identity when the caller re-renders without
110
+ * memoizing, and both effects below would then re-run on it: the resume effect
111
+ * would re-mount the whole sequence (and, since it sets state, re-render, and
112
+ * loop), and the poll timer would be cleared and restarted before it ever
113
+ * ticked — a screen that asks the provider nothing, for ever, while looking
114
+ * exactly like one that is asking.
115
+ *
116
+ * The host that wrote this flow happened to memoize, so neither ever fired
117
+ * there. A package cannot rely on that: `useRedirectActivation({ onVerified:
118
+ * () => reload() })` is the obvious way to call it.
119
+ */
120
+ interface ActivationCallbacks {
121
+ applyPoll: (body: ActivationPollBody | null) => boolean;
122
+ returned: () => Record<string, string>;
123
+ timedOut: string;
124
+ }
125
+
126
+ /**
127
+ * Ask the provider every few seconds, and stop asking eventually.
128
+ *
129
+ * The give-up is a `failed` state carrying the host's own sentence, because the
130
+ * wording is load-bearing: the charge is STILL valid, and an owner who has
131
+ * genuinely paid must not be told they did not pay in time.
132
+ */
133
+ function usePollTimer(
134
+ active: boolean,
135
+ url: string,
136
+ live: ActivationClock,
137
+ latest: React.MutableRefObject<ActivationCallbacks>,
138
+ setState: React.Dispatch<React.SetStateAction<RedirectActivationState>>,
139
+ pollMs: number,
140
+ pollTimeoutMs: number,
141
+ ): void {
142
+ useEffect(() => {
143
+ if (!active) return;
144
+ const timer = setInterval(() => {
145
+ if (!live.current.polling) return;
146
+ if (Date.now() - live.current.startedAt > pollTimeoutMs) {
147
+ live.current.polling = false;
148
+ setState({ kind: 'failed', reason: latest.current.timedOut });
149
+ return;
150
+ }
151
+ // WITH the ids, every tick. A hint-less poll is measured to answer "not
152
+ // paid" for payments that happened — one hinted ask on resume made a
153
+ // single dropped request fatal.
154
+ void postActivation(url, 'poll', latest.current.returned()).then((body) =>
155
+ latest.current.applyPoll(body),
156
+ );
157
+ }, pollMs);
158
+ return () => clearInterval(timer);
159
+ }, [active, url, live, latest, setState, pollMs, pollTimeoutMs]);
160
+ }
161
+
162
+ /**
163
+ * Pick the outstanding charge back up on mount.
164
+ *
165
+ * This is what makes the return trip work at all. The owner is sent to the
166
+ * provider's site to pay and comes back here holding the ids that let the
167
+ * payment be confirmed — so the very first thing this does on load is ask
168
+ * whether a charge is outstanding and, if so, check it immediately with
169
+ * whatever the redirect brought back.
170
+ */
171
+ function useResumeOutstanding(
172
+ url: string,
173
+ live: ActivationClock,
174
+ latest: React.MutableRefObject<ActivationCallbacks>,
175
+ setState: React.Dispatch<React.SetStateAction<RedirectActivationState>>,
176
+ ): void {
177
+ useEffect(() => {
178
+ const alive = { current: true };
179
+ const carried = latest.current.returned();
180
+
181
+ // An owner who came back holding a settlement has DEMONSTRABLY paid, so the
182
+ // screen must be confirming from the first frame — never showing the pay
183
+ // button, whatever the pending row says and whatever any single request
184
+ // does. `checkoutUrl: null` renders the confirming panel without an
185
+ // open-link button. This also arms the timer, so one dropped request costs
186
+ // four seconds rather than the payment.
187
+ if (Object.keys(carried).length > 0) {
188
+ live.current.polling = true;
189
+ live.current.startedAt = Date.now();
190
+ setState({ kind: 'awaiting', checkoutUrl: null });
191
+ }
192
+
193
+ void resumeSequence({ url, live, latest, setState }, carried, alive);
194
+
195
+ return () => {
196
+ alive.current = false;
197
+ };
198
+ }, [url, live, latest, setState]);
199
+ }
200
+
201
+ /** Everything the resume sequence reaches for, gathered once. */
202
+ interface ResumeIo {
203
+ url: string;
204
+ live: ActivationClock;
205
+ latest: React.MutableRefObject<ActivationCallbacks>;
206
+ setState: React.Dispatch<React.SetStateAction<RedirectActivationState>>;
207
+ }
208
+
209
+ /** The resume sequence, in the order that keeps a paid return confirmable. */
210
+ async function resumeSequence(
211
+ io: ResumeIo,
212
+ carried: Record<string, string>,
213
+ alive: { current: boolean },
214
+ ): Promise<void> {
215
+ const cameBack = Object.keys(carried).length > 0;
216
+ // Fired FIRST, and not gated on the pending GET below: this ask depends on
217
+ // nothing but the returned ids, and chaining it behind another request's
218
+ // success is how a mid-refresh session cookie once turned a confirmed payment
219
+ // into a pay button.
220
+ if (cameBack) {
221
+ const polled = await postActivation(io.url, 'poll', carried);
222
+ if (alive.current) io.latest.current.applyPoll(polled);
223
+ }
224
+ await restoreOutstanding(io, carried, alive, cameBack);
225
+ }
226
+
227
+ /**
228
+ * The GET's answer: the outstanding charge, and whether the server settled it
229
+ * during this very read (its own heal path).
230
+ */
231
+ interface OutstandingBody {
232
+ pending?: ActivationPendingBody;
233
+ proven?: boolean;
234
+ }
235
+
236
+ async function readOutstanding(url: string): Promise<OutstandingBody | null> {
237
+ const response = await fetch(url).catch(() => null);
238
+ if (!response?.ok) return null;
239
+ return (await response.json().catch(() => null)) as OutstandingBody | null;
240
+ }
241
+
242
+ /** Restore the server's pending row into the screen, and ask once if idle. */
243
+ async function restoreOutstanding(
244
+ io: ResumeIo,
245
+ carried: Record<string, string>,
246
+ alive: { current: boolean },
247
+ cameBack: boolean,
248
+ ): Promise<void> {
249
+ const body = await readOutstanding(io.url);
250
+ if (!body || !alive.current) return;
251
+ // Settled server-side while we were reading — the charge is done, and the one
252
+ // thing this must not do now is resume a pay flow for it.
253
+ if (body.proven) {
254
+ io.latest.current.applyPoll({ ok: true });
255
+ return;
256
+ }
257
+ const pending = body.pending;
258
+ if (!pending) return;
259
+
260
+ io.live.current.polling = true;
261
+ io.live.current.startedAt = Date.parse(pending.startedAt) || Date.now();
262
+ io.setState((current) =>
263
+ current.kind === 'passed' || current.kind === 'failed'
264
+ ? current
265
+ : { kind: 'awaiting', checkoutUrl: pending.checkoutUrl },
266
+ );
267
+ if (!cameBack) {
268
+ const polled = await postActivation(io.url, 'poll', carried);
269
+ if (alive.current) io.latest.current.applyPoll(polled);
270
+ }
271
+ }
272
+
273
+ export function useRedirectActivation(options: RedirectActivationOptions): RedirectActivation {
274
+ const {
275
+ verifyChargeUrl: url,
276
+ onVerified,
277
+ onCreateFailed,
278
+ copy,
279
+ storageKey,
280
+ pollMs = DEFAULT_POLL_MS,
281
+ pollTimeoutMs = DEFAULT_POLL_TIMEOUT_MS,
282
+ } = options;
283
+
284
+ const [state, setState] = useState<RedirectActivationState>({ kind: 'idle' });
285
+ const [lastCheckedAt, setLastCheckedAt] = useState(0);
286
+ // Read inside the interval callback so the timer never closes over a stale
287
+ // state — mutating a ref's property rather than reassigning a captured
288
+ // binding, which the flakiness lint rightly rejects.
289
+ const live = useRef({ polling: false, startedAt: 0 });
290
+
291
+ const returned = useCallback(() => takeReturnedSettlement(storageKey), [storageKey]);
292
+
293
+ const applyPoll = useCallback(
294
+ (body: ActivationPollBody | null): boolean => {
295
+ // Stamped even on a dropped request: the counter reports when we last
296
+ // ASKED, and freezing it on a network blip would read as a stalled screen.
297
+ setLastCheckedAt(Date.now());
298
+ return settleActivationPoll(body, {
299
+ live,
300
+ setState,
301
+ onVerified,
302
+ clearSettlement: () => clearReturnedSettlement(storageKey),
303
+ copy,
304
+ });
305
+ },
306
+ [onVerified, storageKey, copy],
307
+ );
308
+
309
+ const latest = useRef<ActivationCallbacks>({
310
+ applyPoll,
311
+ returned,
312
+ timedOut: copy.confirmTimedOut,
313
+ });
314
+ latest.current = { applyPoll, returned, timedOut: copy.confirmTimedOut };
315
+
316
+ useResumeOutstanding(url, live, latest, setState);
317
+ usePollTimer(state.kind === 'awaiting', url, live, latest, setState, pollMs, pollTimeoutMs);
318
+
319
+ const start = useCallback(
320
+ () => mintCharge({ url, live, setState, copy, onCreateFailed }),
321
+ [url, copy, onCreateFailed],
322
+ );
323
+
324
+ const checkNow = useCallback(async () => {
325
+ applyPoll(await postActivation(url, 'poll', returned()));
326
+ }, [applyPoll, url, returned]);
327
+
328
+ /**
329
+ * Give up on the outstanding charge — and tell the SERVER so.
330
+ *
331
+ * Clearing only the local state would leave the charge on record, so the very
332
+ * next load would resume the attempt the owner just abandoned.
333
+ */
334
+ const reset = useCallback(() => {
335
+ live.current.polling = false;
336
+ setState({ kind: 'idle' });
337
+ void postActivation(url, 'discard');
338
+ }, [url]);
339
+
340
+ return { state, lastCheckedAt, start, checkNow, reset };
341
+ }
@@ -3,12 +3,7 @@ import { useEffect, useRef, type JSX, type ReactNode } from "react";
3
3
 
4
4
  import { BuyerInfoForm } from "./buyer-info-form";
5
5
  import { LockOutlinedIcon } from "./icons";
6
- import {
7
- cardPathAvailable,
8
- offeredMethods,
9
- selectableMethods,
10
- usePreselectSoleMethod,
11
- } from "./method-capability";
6
+ import { useMethodChoice } from "./method-choice";
12
7
  import { MethodPicker } from "./method-picker";
13
8
  import { PaymentErrorPanel } from "./payment-error-panel";
14
9
  import { PayerSummary } from "./payer-summary";
@@ -42,8 +37,8 @@ function useAutoRaiseOrder(
42
37
  requestedFor.current = null;
43
38
  return;
44
39
  }
45
- // No method chosen yet ⇒ show only the picker; raise the order once the
46
- // buyer selects PIX or card.
40
+ // No method chosen yet ⇒ show only the picker (or, for a hand-off store,
41
+ // its "Seguir para o pagamento"); raise the order once the buyer commits.
47
42
  if (!method || creating || createError || requestedFor.current === method) return;
48
43
  requestedFor.current = method;
49
44
  onGenerate(method);
@@ -70,6 +65,8 @@ function PaymentBody({
70
65
  method,
71
66
  tenantSlug,
72
67
  onResolved,
68
+ onStart,
69
+ creating,
73
70
  pollIntervalMs,
74
71
  validateApplePayMerchant,
75
72
  }: {
@@ -79,6 +76,9 @@ function PaymentBody({
79
76
  method: PaymentMethod | null;
80
77
  tenantSlug?: string;
81
78
  onResolved: (status: OrderStatus) => void;
79
+ /** Set only when the shell hid its picker — see {@link PaymentStep}. */
80
+ onStart?: () => void;
81
+ creating: boolean;
82
82
  pollIntervalMs?: number;
83
83
  validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
84
84
  }): JSX.Element | null {
@@ -91,6 +91,8 @@ function PaymentBody({
91
91
  method={method}
92
92
  tenantSlug={tenantSlug}
93
93
  onResolved={onResolved}
94
+ onStart={onStart}
95
+ creating={creating}
94
96
  pollIntervalMs={pollIntervalMs}
95
97
  validateApplePayMerchant={validateApplePayMerchant}
96
98
  />
@@ -303,6 +305,17 @@ interface PaymentStepProps {
303
305
  * Step 2 "Pagamento" — pick PIX or card and pay on the SAME page. Selecting a
304
306
  * method auto-raises its order and reveals its UI (PIX QR / card form) with no
305
307
  * intermediate tap; switching method clears the previous order (controller).
308
+ *
309
+ * ## Unless the choice is not ours to ask
310
+ *
311
+ * A store that finishes checkout on the provider's own page gets NO picker
312
+ * here (`methodChosenAtProvider`). Its screen renders a single "Seguir para o
313
+ * pagamento" instead, and pressing it selects the store's hand-off method —
314
+ * which is the same event a tile press is, so the auto-raise, the error panel
315
+ * and the retry below all keep working unchanged. Preselection is suppressed
316
+ * for the same flow, and deliberately: it exists to spare a buyer a tap that
317
+ * buys them nothing, but here the tap is the buyer's consent to LEAVE, and
318
+ * taking it for them would redirect a checkout the moment it rendered.
306
319
  */
307
320
  export function PaymentStep({
308
321
  method,
@@ -323,35 +336,42 @@ export function PaymentStep({
323
336
  onResolved,
324
337
  }: PaymentStepProps): JSX.Element {
325
338
  const { LoadingState } = useCheckoutComponents();
326
- const cardUnavailable = !cardPathAvailable(providerConfig ?? null);
327
- const offered = offeredMethods(providerConfig ?? null);
339
+ const config = providerConfig ?? null;
340
+ const choice = useMethodChoice(config, method, onMethodChange);
328
341
  useAutoRaiseOrder(order, method, creating, createError, onGenerate);
329
- usePreselectSoleMethod(selectableMethods(offered, cardUnavailable), method, onMethodChange);
330
342
 
331
343
  return (
332
344
  <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
333
345
  {/* Self-hiding: renders only for a flow whose Dados step was skipped. */}
334
346
  <PayerSummary name={buyer.name} taxId={buyer.taxId} onEdit={onEditBuyer} />
335
347
 
336
- <MethodPicker
337
- value={method}
338
- onChange={onMethodChange}
339
- cardUnavailable={cardUnavailable}
340
- offered={offered}
341
- />
348
+ {choice.atProvider ? null : (
349
+ <MethodPicker
350
+ value={method}
351
+ onChange={onMethodChange}
352
+ cardUnavailable={choice.cardUnavailable}
353
+ offered={choice.offered}
354
+ />
355
+ )}
342
356
 
343
357
  <PaymentBody
344
358
  order={order}
345
359
  buyer={buyer}
346
- providerConfig={providerConfig ?? null}
360
+ providerConfig={config}
347
361
  method={method}
348
362
  tenantSlug={tenantSlug}
349
363
  onResolved={onResolved}
364
+ onStart={choice.onStart}
365
+ creating={creating}
350
366
  pollIntervalMs={pollIntervalMs}
351
367
  validateApplePayMerchant={validateApplePayMerchant}
352
368
  />
353
369
 
354
- {!order && creating ? (
370
+ {/* The shell's own busy spinner, SUPPRESSED for a hand-off screen: that
371
+ screen renders its own "Preparando o pagamento" while the charge is
372
+ raised, and two stacked spinners saying the same thing is what the
373
+ buyer actually saw. */}
374
+ {!order && creating && !choice.atProvider ? (
355
375
  <LoadingState variant="spinner" size="md" message="Gerando pagamento…" dataTestId="payment-generating" />
356
376
  ) : null}
357
377
 
@@ -216,6 +216,33 @@ export function selectableMethods(
216
216
  );
217
217
  }
218
218
 
219
+ /**
220
+ * The method a HAND-OFF checkout raises on the buyer's behalf, when the buyer
221
+ * was never asked (see `providers/registry.ts`'s `methodChosenAtProvider`).
222
+ *
223
+ * A charge still has to be raised with SOME method — that is the wire's shape,
224
+ * and the link is minted by raising it — but for a hand-off store the value is
225
+ * provisional by construction: the buyer picks for real on the provider's page,
226
+ * and the settlement reports back which one they actually used. So the choice
227
+ * here is about which request the SERVER will honour, not about what the buyer
228
+ * gets.
229
+ *
230
+ * PIX when the chain offers it, because PIX is the one method whose first
231
+ * charge is always raised immediately: a CARD request at a store that can
232
+ * tokenize somewhere in its chain is answered with the bare order instead, and
233
+ * there is no hand-off link on that answer to send anyone anywhere. Otherwise
234
+ * the chain's first declared method, since a store that cannot PIX would have
235
+ * the walk refuse a PIX charge outright.
236
+ *
237
+ * `null` offered — still loading, or a fetch blip — cannot reach here: the
238
+ * picker is only hidden for a store whose config already said it hands over.
239
+ * PIX is the safe reading of it anyway, for the reason above.
240
+ */
241
+ export function handOffMethod(offered: PaymentMethod[] | null): PaymentMethod {
242
+ if (!offered || offered.includes("PIX")) return "PIX";
243
+ return offered[0] ?? "PIX";
244
+ }
245
+
219
246
  /**
220
247
  * A SOLE remaining method is not a choice — take it (FUT-697 review, widened by
221
248
  * FUT-741).
@@ -0,0 +1,64 @@
1
+ /**
2
+ * WHO asks the buyer PIX-or-card, and what the Pagamento step does about it.
3
+ *
4
+ * Its own module because the answer is a set of derived facts that must move
5
+ * together: a picker rendered for a store that hands the buyer over, or a
6
+ * hand-off CTA rendered beside a picker, are both a checkout asking one
7
+ * question twice — which is the defect this seam exists to make impossible.
8
+ */
9
+ import {
10
+ cardPathAvailable,
11
+ handOffMethod,
12
+ offeredMethods,
13
+ selectableMethods,
14
+ usePreselectSoleMethod,
15
+ } from "./method-capability";
16
+ import { methodChosenAtProvider } from "./providers/registry";
17
+ import type { CheckoutProviderConfig, PaymentMethod } from "./types";
18
+
19
+ /** What the Pagamento step needs to know about WHO asks the buyer for a method. */
20
+ interface MethodChoice {
21
+ /** The store's active provider has no card path in this browser. */
22
+ cardUnavailable: boolean;
23
+ /** The methods the chain declares it can charge, or `null` while unknown. */
24
+ offered: PaymentMethod[] | null;
25
+ /** The choice is made on the provider's page ⇒ render no picker here. */
26
+ atProvider: boolean;
27
+ /**
28
+ * The hand-off screen's "start paying" port, or `undefined` when the picker
29
+ * is on the page and owns that job instead. It SELECTS the store's hand-off
30
+ * method, which is the same event a tile press is — so the auto-raise, the
31
+ * error panel and its retry all keep working with no second code path.
32
+ */
33
+ onStart?: () => void;
34
+ }
35
+
36
+ /**
37
+ * Resolve who asks the buyer PIX-or-card, and preselect a sole method when the
38
+ * question is ours to ask.
39
+ */
40
+ export function useMethodChoice(
41
+ config: CheckoutProviderConfig | null,
42
+ method: PaymentMethod | null,
43
+ onMethodChange: (method: PaymentMethod) => void,
44
+ ): MethodChoice {
45
+ const cardUnavailable = !cardPathAvailable(config);
46
+ const offered = offeredMethods(config);
47
+ const atProvider = methodChosenAtProvider(config?.chain?.[0]?.checkoutScreen, config);
48
+ // Nothing to preselect when the screen owns the choice: the whole point of
49
+ // its button is that the buyer presses it. Preselection exists to spare them
50
+ // a tap that buys them nothing, and here the tap is their consent to LEAVE —
51
+ // taking it for them would redirect a checkout the moment it rendered.
52
+ usePreselectSoleMethod(
53
+ atProvider ? [] : selectableMethods(offered, cardUnavailable),
54
+ method,
55
+ onMethodChange,
56
+ );
57
+ if (!atProvider) return { cardUnavailable, offered, atProvider };
58
+ return {
59
+ cardUnavailable,
60
+ offered,
61
+ atProvider,
62
+ onStart: () => onMethodChange(handOffMethod(offered)),
63
+ };
64
+ }
@@ -19,45 +19,11 @@
19
19
  */
20
20
  import type { JSX } from "react";
21
21
 
22
- import type { CheckoutProviderConfig } from "../types";
23
-
22
+ import { handsBuyerOver } from "./hands-over";
24
23
  import { HostedLinkScreen } from "./hosted-link";
25
24
  import { PixAndCardScreen } from "./pix-and-card";
26
25
  import type { ProviderCheckoutScreenProps } from "./types";
27
26
 
28
- /** Schemes that give the BROWSER a card form of its own. */
29
- const IN_BROWSER_TOKENIZATION: ReadonlySet<string> = new Set(["PUBLIC_KEY", "SDK"]);
30
-
31
- /**
32
- * Whether this store hands the buyer over instead of collecting here — the
33
- * frontend twin of the server's `usesHostedCheckout`, deliberately written to
34
- * the same three rules so the pane and the walk cannot disagree.
35
- *
36
- * Note this is NOT `!cardPathAvailable(config)`. That helper answers a
37
- * different question — "is a card offerable at all" — and it answers TRUE for
38
- * a hand-off store, because typing the card on the provider's page is still a
39
- * card path. Inverting it therefore sends the hosted store to the on-page
40
- * screen and the on-page store to the hand-off, which is exactly backwards.
41
- *
42
- * The rules, in order:
43
- * - Only CARD can be answered in advance. TOKENIZATION IS A CARD FACT: it
44
- * says how the browser turns a PAN into an instrument, and a PIX charge
45
- * has no instrument to mint. A store with no card-capable entry is not
46
- * hosted — this is the FUT-747 correction, and getting it wrong routed the
47
- * simplest store there is (one PIX-only provider honestly declaring
48
- * `NONE`) into a hand-off it had no link for.
49
- * - Hosted only when NOBODY who takes a card takes it here.
50
- * - No chain served (an older host, a still-loading config) ⇒ not hosted,
51
- * which is what this checkout did before there was a chain to read.
52
- */
53
- function handsBuyerOver(config: CheckoutProviderConfig | null): boolean {
54
- const chain = config?.chain;
55
- if (!chain || chain.length === 0) return false;
56
- const cardCapable = chain.filter((link) => link.methods.includes("CARD"));
57
- if (cardCapable.length === 0) return false;
58
- return !cardCapable.some((link) => IN_BROWSER_TOKENIZATION.has(link.tokenization));
59
- }
60
-
61
27
  export function CapabilityDefaultScreen(props: ProviderCheckoutScreenProps): JSX.Element | null {
62
28
  return handsBuyerOver(props.config) ? (
63
29
  <HostedLinkScreen {...props} />
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Whether a store finishes checkout on the PROVIDER's own page.
3
+ *
4
+ * Its own module because two callers need the same answer and must never be
5
+ * able to disagree about it: {@link CapabilityDefaultScreen}, which picks the
6
+ * pane for a provider that declared no screen, and the shell's picker gate,
7
+ * which decides whether the buyer is asked PIX-or-card here at all. It lived
8
+ * inside `capability-default.tsx` while there was one caller.
9
+ */
10
+ import type { CheckoutProviderConfig } from "../types";
11
+
12
+ /** Schemes that give the BROWSER a card form of its own. */
13
+ const IN_BROWSER_TOKENIZATION: ReadonlySet<string> = new Set(["PUBLIC_KEY", "SDK"]);
14
+
15
+ /**
16
+ * Whether this store hands the buyer over instead of collecting here — the
17
+ * frontend twin of the server's `usesHostedCheckout`, deliberately written to
18
+ * the same three rules so the pane and the walk cannot disagree.
19
+ *
20
+ * Note this is NOT `!cardPathAvailable(config)`. That helper answers a
21
+ * different question — "is a card offerable at all" — and it answers TRUE for
22
+ * a hand-off store, because typing the card on the provider's page is still a
23
+ * card path. Inverting it therefore sends the hosted store to the on-page
24
+ * screen and the on-page store to the hand-off, which is exactly backwards.
25
+ *
26
+ * The rules, in order:
27
+ * - Only CARD can be answered in advance. TOKENIZATION IS A CARD FACT: it
28
+ * says how the browser turns a PAN into an instrument, and a PIX charge
29
+ * has no instrument to mint. A store with no card-capable entry is not
30
+ * hosted — this is the FUT-747 correction, and getting it wrong routed the
31
+ * simplest store there is (one PIX-only provider honestly declaring
32
+ * `NONE`) into a hand-off it had no link for.
33
+ * - Hosted only when NOBODY who takes a card takes it here.
34
+ * - No chain served (an older host, a still-loading config) ⇒ not hosted,
35
+ * which is what this checkout did before there was a chain to read.
36
+ */
37
+ export function handsBuyerOver(config: CheckoutProviderConfig | null): boolean {
38
+ const chain = config?.chain;
39
+ if (!chain || chain.length === 0) return false;
40
+ const cardCapable = chain.filter((link) => link.methods.includes("CARD"));
41
+ if (cardCapable.length === 0) return false;
42
+ return !cardCapable.some((link) => IN_BROWSER_TOKENIZATION.has(link.tokenization));
43
+ }