@12-apps/payments-frontend 3.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
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": {
@@ -17,7 +17,7 @@
17
17
  "storybook:build": "storybook build"
18
18
  },
19
19
  "dependencies": {
20
- "@12-apps/payments-backend": "^4.13.0",
20
+ "@12-apps/payments-backend": "^4.14.0",
21
21
  "react-qr-code": "^2.2.0"
22
22
  },
23
23
  "peerDependencies": {
@@ -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
+ }
@@ -15,26 +15,112 @@
15
15
  * is leaving this page and parking the payable for the return leg matters more
16
16
  * than painting a pane they will not see. `hosted-return.ts` picks it back up.
17
17
  *
18
- * So the whole job here is the moment BEFORE that: the buyer has chosen a
19
- * method and the redirect is being prepared. Previously the pane rendered
20
- * `null` through that moment, which reads as a checkout that has stalled — the
21
- * screen tells them where they are going instead. Nothing here touches the
22
- * money path; the navigation and the parking are unchanged.
18
+ * So the whole job here is the moment BEFORE that: telling the buyer where
19
+ * they are about to go, and giving them the one action that takes them.
20
+ *
21
+ * ## Why the ACTION lives here and not in the shell's picker
22
+ *
23
+ * The shell hides its PIX/card picker for this screen (`methodChosenAtProvider`
24
+ * in `./registry.ts`), so the screen owns the affordance that starts the
25
+ * charge. That is the point of the whole arrangement: the method question is
26
+ * binding on the provider's page, not on ours — every answer mints the same
27
+ * link — so asking it twice tells the buyer their first answer was thrown
28
+ * away. One button that says where it leads is the honest version of that
29
+ * screen, and it also removes the moment where the pane rendered `null` under
30
+ * a picker, which reads as a checkout that has stalled.
23
31
  */
24
32
  import { Box } from "@mui/material";
25
33
  import type { JSX } from "react";
26
34
 
35
+ import { offeredMethods } from "../method-capability";
36
+ import type { CheckoutProviderConfig } from "../types";
27
37
  import { useCheckoutComponents } from "../ui";
28
38
 
29
39
  import type { ProviderCheckoutScreenProps } from "./types";
30
40
 
31
- export function HostedLinkScreen({ method }: ProviderCheckoutScreenProps): JSX.Element | null {
32
- const { Text, LoadingState } = useCheckoutComponents();
41
+ /**
42
+ * How the store's own page is named to the buyer — "à página segura da
43
+ * InfinitePay", or the provider-neutral phrasing when no name was published.
44
+ *
45
+ * The neutral form is not a lesser fallback to be tidied away later: a host one
46
+ * release behind serves no `displayName`, and the buyer of that store gets a
47
+ * true sentence rather than our internal id ("infinitepay") dressed up as a
48
+ * brand.
49
+ */
50
+ function destinationLabel(config: CheckoutProviderConfig | null): string {
51
+ const name = config?.chain?.[0]?.displayName?.trim();
52
+ return name ? `à página de pagamento da ${name}` : "à página de pagamento segura do provedor";
53
+ }
33
54
 
34
- // Nothing chosen yet ⇒ the shell is still showing the picker. Same as every
35
- // other screen: the pane stays out of the way until there is something to say.
36
- if (!method) return null;
55
+ /**
56
+ * What the buyer will be asked to choose between once they get there, so the
57
+ * sentence promises exactly what the provider's page offers.
58
+ *
59
+ * Read from the same declaration the picker used to render, which is what
60
+ * keeps this honest for a provider that takes only one of the two: a hosted
61
+ * PIX-only store must not promise a card.
62
+ */
63
+ function methodsPhrase(config: CheckoutProviderConfig | null): string | null {
64
+ const offered = offeredMethods(config);
65
+ const pix = offered === null || offered.includes("PIX");
66
+ const card = offered === null || offered.includes("CARD");
67
+ if (pix && card) return "PIX ou cartão";
68
+ if (pix) return "PIX";
69
+ if (card) return "cartão";
70
+ return null;
71
+ }
72
+
73
+ /** The full "where you are going and what happens there" sentence. */
74
+ function handoffMessage(config: CheckoutProviderConfig | null): string {
75
+ const methods = methodsPhrase(config);
76
+ const choice = methods ? `, onde você escolhe pagar com ${methods}` : "";
77
+ return `Você será levado ${destinationLabel(config)}${choice}.`;
78
+ }
79
+
80
+ /** The invitation: what happens next, and the one button that starts it. */
81
+ function HandOffInvite({
82
+ config,
83
+ onStart,
84
+ }: {
85
+ config: CheckoutProviderConfig | null;
86
+ onStart: () => void;
87
+ }): JSX.Element {
88
+ const { Button, Text } = useCheckoutComponents();
89
+ return (
90
+ <Box
91
+ data-testid="checkout-handoff-invite"
92
+ sx={{ display: "flex", flexDirection: "column", gap: 2 }}
93
+ >
94
+ <Text variant="body" size="md" as="p">
95
+ {handoffMessage(config)}
96
+ </Text>
97
+ <Text variant="caption" size="sm" as="p" color="secondary">
98
+ Assim que o pagamento for concluído, você volta para cá e nós confirmamos o pedido.
99
+ </Text>
100
+ <Button
101
+ variant="solid"
102
+ color="primary"
103
+ size="lg"
104
+ fullWidth
105
+ onClick={onStart}
106
+ dataTestId="checkout-handoff-start"
107
+ >
108
+ Seguir para o pagamento
109
+ </Button>
110
+ </Box>
111
+ );
112
+ }
37
113
 
114
+ /**
115
+ * The moment after the button: the charge is being raised, then we navigate.
116
+ *
117
+ * Says the SAME sentence the invite did, from the same helper. It used to word
118
+ * the destination differently — "a página segura do provedor" against the
119
+ * invite's named one — so the screen appeared to change its mind about where
120
+ * the buyer was going at the exact moment they committed to going there.
121
+ */
122
+ function HandOffPending({ config }: { config: CheckoutProviderConfig | null }): JSX.Element {
123
+ const { Text, LoadingState } = useCheckoutComponents();
38
124
  return (
39
125
  <Box
40
126
  data-testid="checkout-handoff-pending"
@@ -42,11 +128,26 @@ export function HostedLinkScreen({ method }: ProviderCheckoutScreenProps): JSX.E
42
128
  >
43
129
  <LoadingState variant="spinner" message="Preparando o pagamento" size="md" />
44
130
  <Text variant="body" size="md" as="p">
45
- Você será levado à página segura do provedor para concluir o pagamento.
131
+ {handoffMessage(config)}
46
132
  </Text>
47
133
  <Text variant="caption" size="sm" as="p" color="secondary">
48
- Assim que terminar, você volta para cá e nós confirmamos o pedido.
134
+ Assim que o pagamento for concluído, você volta para cá e nós confirmamos o pedido.
49
135
  </Text>
50
136
  </Box>
51
137
  );
52
138
  }
139
+
140
+ export function HostedLinkScreen({
141
+ method,
142
+ config,
143
+ onStart,
144
+ }: ProviderCheckoutScreenProps): JSX.Element | null {
145
+ // A method is only ever set here once the buyer has committed — either by
146
+ // pressing the CTA below, or (on a host whose shell still renders a picker)
147
+ // by choosing a tile. Both mean the same thing: the hand-off is underway.
148
+ if (method) return <HandOffPending config={config} />;
149
+ // No method and no CTA to offer ⇒ the shell is still showing its picker, and
150
+ // the pane stays out of the way exactly as every other screen does.
151
+ if (!onStart) return null;
152
+ return <HandOffInvite config={config} onStart={onStart} />;
153
+ }
@@ -26,8 +26,10 @@
26
26
  * to an empty pane.
27
27
  */
28
28
  import { CapabilityDefaultScreen } from "./capability-default";
29
+ import { handsBuyerOver } from "./hands-over";
29
30
  import { HostedLinkScreen } from "./hosted-link";
30
31
  import { PixAndCardScreen } from "./pix-and-card";
32
+ import type { CheckoutProviderConfig } from "../types";
31
33
  import type { ProviderCheckoutScreen } from "./types";
32
34
 
33
35
  /**
@@ -76,3 +78,31 @@ export function resolveCheckoutScreen(
76
78
  ): ProviderCheckoutScreen {
77
79
  return screenFor(chainHeadScreen) ?? CapabilityDefaultScreen;
78
80
  }
81
+
82
+ /**
83
+ * Does the buyer choose PIX-or-card on the PROVIDER's page rather than on ours?
84
+ *
85
+ * The shell asks this before rendering its method picker, and hides the picker
86
+ * when the answer is yes (FUT-596 follow-up). A hand-off store asks the
87
+ * question twice otherwise: once here, where the answer changes nothing —
88
+ * every method mints the same checkout link — and again on the provider's own
89
+ * page, where it is finally binding. The first ask is not merely redundant, it
90
+ * is misleading: the buyer who picked PIX here has picked nothing, and finding
91
+ * the same two options waiting for them on another site reads as a checkout
92
+ * that lost their answer.
93
+ *
94
+ * Resolved in the SAME order as {@link resolveCheckoutScreen} — a declared id
95
+ * wins, and an undeclared or unknown one falls back to the capability read —
96
+ * so the picker and the pane can never disagree about which flow this is. That
97
+ * is why the test is an identity check against the hand-off screen rather than
98
+ * a second table of ids: a table would be the copy that drifts, and it would
99
+ * drift the moment a new hand-off adapter declared its own id.
100
+ */
101
+ export function methodChosenAtProvider(
102
+ chainHeadScreen: string | null | undefined,
103
+ config: CheckoutProviderConfig | null,
104
+ ): boolean {
105
+ const declared = screenFor(chainHeadScreen);
106
+ if (declared) return declared === HostedLinkScreen;
107
+ return handsBuyerOver(config);
108
+ }
@@ -46,8 +46,26 @@ export interface ProviderCheckoutScreenProps {
46
46
  * those readings call the helpers themselves.
47
47
  */
48
48
  config: CheckoutProviderConfig | null;
49
- /** What the shell's picker currently has selected; `null` before a choice. */
49
+ /**
50
+ * What the shell currently has selected; `null` before a choice.
51
+ *
52
+ * `null` is also the resting state of a screen that OWNS the choice (see
53
+ * {@link ProviderCheckoutScreenProps.onStart}): nothing is selected until
54
+ * the buyer presses that screen's own CTA.
55
+ */
50
56
  method: PaymentMethod | null;
57
+ /**
58
+ * Present ⇒ this screen owns the "how do I start paying" affordance, because
59
+ * the shell has hidden its method picker for it (FUT-596 follow-up). Calling
60
+ * it commits the buyer to the store's hand-off method and raises the charge,
61
+ * exactly as choosing a tile in the picker does.
62
+ *
63
+ * Absent for every screen where the picker is still on the page — those
64
+ * screens must not grow a second way to start the same charge.
65
+ */
66
+ onStart?: () => void;
67
+ /** A charge is being raised right now — the shell's own busy flag. */
68
+ creating?: boolean;
51
69
  /** Scopes the saved-card list to the store being paid. */
52
70
  tenantSlug?: string;
53
71
  /** The shell's polling cadence, passed through so tests can shorten it. */
@@ -250,6 +250,17 @@ export interface CheckoutCustomerField {
250
250
  */
251
251
  export interface CheckoutChainLink {
252
252
  provider: string;
253
+ /**
254
+ * The provider's own name as a BUYER should read it ("InfinitePay"), which
255
+ * `GET /api/checkout/config` publishes per entry.
256
+ *
257
+ * Optional, and the DEGRADE DIRECTION IS "SAY NOTHING": an older host serves
258
+ * no name, and a hand-off screen then describes where the buyer is going
259
+ * without naming it, rather than printing the internal `provider` id — a
260
+ * buyer shown "infinitepay" learns less than one shown nothing, and learns
261
+ * it in a vocabulary that is ours rather than theirs.
262
+ */
263
+ displayName?: string | null;
253
264
  tokenization: "NONE" | "PUBLIC_KEY" | "SDK" | "REDIRECT";
254
265
  publicKey: string | null;
255
266
  mockTokenization: boolean;