@12-apps/payments-frontend 1.3.0 → 1.4.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": "1.3.0",
3
+ "version": "1.4.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": {
@@ -15,6 +15,7 @@
15
15
  "typecheck": "tsc --noEmit"
16
16
  },
17
17
  "dependencies": {
18
+ "@12-apps/payments-backend": "^1.4.0",
18
19
  "react-qr-code": "^2.2.0"
19
20
  },
20
21
  "peerDependencies": {
@@ -28,9 +29,8 @@
28
29
  "@emotion/react": "^11.14.0",
29
30
  "@emotion/styled": "^11.14.0",
30
31
  "@mui/material": "^6.5.0",
31
- "@12-apps/eslint-config": "^1.4.0",
32
- "@12-apps/payments-backend": "^1.3.0",
33
- "@12-apps/typescript-config": "^1.4.0",
32
+ "@12-apps/eslint-config": "^1.5.0",
33
+ "@12-apps/typescript-config": "^1.5.0",
34
34
  "@testing-library/react": "^16.1.0",
35
35
  "@types/react": "19.2.2",
36
36
  "@types/react-dom": "19.2.2",
@@ -37,6 +37,8 @@ export async function tokenizeWithStripe(
37
37
  publicKey: string,
38
38
  brand: string,
39
39
  last4: string,
40
+ /** Deadline for the round trip; an abort reads as "could not contact". */
41
+ signal?: AbortSignal,
40
42
  ): Promise<Result<CardToken>> {
41
43
  const match = /^(\d{2})\/(\d{2})$/.exec(card.expiry.trim());
42
44
  if (!match) return err("Validade inválida ou expirada.");
@@ -60,6 +62,7 @@ export async function tokenizeWithStripe(
60
62
  Authorization: `Bearer ${publicKey}`,
61
63
  },
62
64
  body: body.toString(),
65
+ signal,
63
66
  });
64
67
  } catch {
65
68
  return err("Não foi possível contatar o provedor do cartão. Verifique sua conexão.");
@@ -157,6 +157,8 @@ async function tokenizeWithPagarme(
157
157
  publicKey: string,
158
158
  brand: string,
159
159
  last4: string,
160
+ /** Deadline for the round trip; an abort reads as "could not contact". */
161
+ signal?: AbortSignal,
160
162
  ): Promise<Result<CardToken>> {
161
163
  const match = /^(\d{2})\/(\d{2})$/.exec(card.expiry.trim());
162
164
  if (!match) return err("Validade inválida ou expirada.");
@@ -166,6 +168,7 @@ async function tokenizeWithPagarme(
166
168
  response = await fetch(`${PAGARME_TOKENS_URL}?appId=${encodeURIComponent(publicKey)}`, {
167
169
  method: "POST",
168
170
  headers: { "Content-Type": "application/json" },
171
+ signal,
169
172
  body: JSON.stringify({
170
173
  type: "card",
171
174
  card: {
@@ -203,6 +206,11 @@ export async function tokenizeCard(
203
206
  card: CardDetails,
204
207
  publicKey: string | null | undefined,
205
208
  tokenizer: CardTokenizer = "pagbank-sdk",
209
+ /**
210
+ * Bounds the network schemes (Pagar.me / Stripe). The PagBank one encrypts
211
+ * locally and has no request to abort.
212
+ */
213
+ signal?: AbortSignal,
206
214
  ): Promise<Result<CardToken>> {
207
215
  const validationError = validateCardInput(card);
208
216
  if (validationError) return err(validationError);
@@ -219,10 +227,10 @@ export async function tokenizeCard(
219
227
  const last4 = pan.slice(-4);
220
228
 
221
229
  if (tokenizer === "pagarme-token") {
222
- return tokenizeWithPagarme(card, pan, publicKey, brand, last4);
230
+ return tokenizeWithPagarme(card, pan, publicKey, brand, last4, signal);
223
231
  }
224
232
  if (tokenizer === "stripe-pm") {
225
- return tokenizeWithStripe(card, pan, publicKey, brand, last4);
233
+ return tokenizeWithStripe(card, pan, publicKey, brand, last4, signal);
226
234
  }
227
235
 
228
236
  if (!(await ensurePagBankSdk())) {
@@ -274,9 +282,15 @@ const CARD_PATH_UNAVAILABLE =
274
282
  export async function tokenizeForCheckout(
275
283
  card: CardDetails,
276
284
  config: CardTokenizationConfig,
285
+ /**
286
+ * Optional deadline for a provider that mints over the network. The chain
287
+ * path passes one so a backup acquirer nobody can reach cannot hold the
288
+ * buyer's Pagar button (FUT-563).
289
+ */
290
+ signal?: AbortSignal,
277
291
  ): Promise<Result<CardToken>> {
278
292
  const scheme = config.provider ? tokenizerFor(config.provider) : null;
279
- if (scheme && config.publicKey) return tokenizeCard(card, config.publicKey, scheme);
293
+ if (scheme && config.publicKey) return tokenizeCard(card, config.publicKey, scheme, signal);
280
294
 
281
295
  if (!config.mockTokenization) return err(CARD_PATH_UNAVAILABLE);
282
296
 
@@ -0,0 +1,244 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * FUT-563 — "o comprador digita o cartão UMA vez e a compra sobrevive ao
4
+ * primeiro provedor falhar": the browser half of it.
5
+ *
6
+ * A card instrument is bound to the provider that minted it, so a chain the
7
+ * checkout tokenized for only ONCE is a chain the gateway can attempt only
8
+ * once — it skips the rest rather than send a blob they cannot read. What is
9
+ * pinned here is that the same validated card produces one instrument per
10
+ * chain entry and they all travel on ONE charge, with nothing re-typed.
11
+ *
12
+ * The browser journey (the `cadeia-de-provedores` Gherkin feature)
13
+ * covers the happy path end to end; these are the halves it cannot stage — a
14
+ * tail provider whose tokenization fails while the head's works, and a store
15
+ * whose host published no chain at all.
16
+ */
17
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
+
20
+ import type { JSX } from "react";
21
+
22
+ const client = vi.hoisted(() => ({
23
+ chargeCard: vi.fn(),
24
+ listSavedCards: vi.fn(),
25
+ refreshCardPublicKey: vi.fn(),
26
+ pollOrderStatus: vi.fn(),
27
+ fetchCheckoutConfig: vi.fn(),
28
+ }));
29
+
30
+ vi.mock("../client", () => client);
31
+
32
+ import type { CardTokenizationConfig } from "../../../card";
33
+ import type { CardChainLink } from "../method-capability";
34
+ import type { CheckoutOrder } from "../types";
35
+ import { useCardCheckout } from "../use-card-checkout";
36
+
37
+ const ORDER: CheckoutOrder = {
38
+ orderId: "o1",
39
+ status: "AWAITING_PAYMENT",
40
+ method: "CARD",
41
+ totalCents: 1250,
42
+ subtotalCents: 1250,
43
+ discountTotalCents: 0,
44
+ appliedDiscounts: [],
45
+ totalLabel: "R$ 12,50",
46
+ };
47
+
48
+ /** Stub-mode entries: the mock tokenizer mints without a real key or network. */
49
+ const HEAD: CardChainLink = {
50
+ provider: "pagbank",
51
+ publicKey: null,
52
+ mockTokenization: true,
53
+ mintable: true,
54
+ };
55
+ const TAIL: CardChainLink = {
56
+ provider: "stone",
57
+ publicKey: null,
58
+ mockTokenization: true,
59
+ mintable: true,
60
+ };
61
+ /**
62
+ * Declares an in-browser scheme, but this browser has none for it: no
63
+ * tokenizer, no key, no mock grant. The mint FAILS — a different thing from
64
+ * an entry that asks for no instrument.
65
+ */
66
+ const UNMINTABLE: CardChainLink = {
67
+ provider: "unknown-vendor",
68
+ publicKey: null,
69
+ mockTokenization: false,
70
+ mintable: true,
71
+ };
72
+ /** A hosted page: its own site takes the card, so nothing is minted for it. */
73
+ const HOSTED: CardChainLink = {
74
+ provider: "infinitepay",
75
+ publicKey: null,
76
+ mockTokenization: false,
77
+ mintable: false,
78
+ };
79
+ /**
80
+ * The ACTIVE provider of a hosted-headed store, as the config's head triple:
81
+ * no scheme, no key, no stub grant. Nothing this browser can mint against.
82
+ */
83
+ const HOSTED_HEAD: CardTokenizationConfig = {
84
+ provider: "infinitepay",
85
+ publicKey: null,
86
+ mockTokenization: false,
87
+ };
88
+
89
+ const CARD = { number: "4111111111111111", holder: "VERA CADEIA", expiry: "12/34", cvv: "123" };
90
+
91
+ /**
92
+ * Filling and paying are two BUTTONS, not one handler: `handlePay` closes over
93
+ * the card in state, so a submit fired in the same tick as `setCard` validates
94
+ * the empty form and never reaches the tokenizer.
95
+ */
96
+ function Harness({
97
+ chain,
98
+ config = HEAD,
99
+ }: {
100
+ chain: CardChainLink[];
101
+ config?: CardTokenizationConfig;
102
+ }): JSX.Element {
103
+ const cc = useCardCheckout(ORDER, {}, config, vi.fn(), 10, undefined, chain);
104
+ return (
105
+ <div>
106
+ <button type="button" data-testid="fill" onClick={() => cc.setCard(CARD)}>
107
+ Preencher
108
+ </button>
109
+ <button type="button" data-testid="pay" onClick={() => void cc.handlePay()}>
110
+ Pagar
111
+ </button>
112
+ {cc.error ? <p data-testid="pay-error">{cc.error}</p> : null}
113
+ </div>
114
+ );
115
+ }
116
+
117
+ /** The `tokensByProvider` of the single charge submitted. */
118
+ function sentInstruments(): Record<string, string> | undefined {
119
+ const input = client.chargeCard.mock.calls[0]?.[0] as
120
+ | { tokensByProvider?: Record<string, string> }
121
+ | undefined;
122
+ return input?.tokensByProvider;
123
+ }
124
+
125
+ async function payWith(chain: CardChainLink[], config?: CardTokenizationConfig): Promise<void> {
126
+ render(<Harness chain={chain} config={config} />);
127
+ await waitFor(() => {
128
+ expect(client.listSavedCards).toHaveBeenCalled();
129
+ });
130
+ fireEvent.click(screen.getByTestId("fill"));
131
+ fireEvent.click(screen.getByTestId("pay"));
132
+ await waitFor(() => {
133
+ expect(client.chargeCard).toHaveBeenCalled();
134
+ });
135
+ }
136
+
137
+ /** Fill + tap Pagar, then prove the submit never reached the charge route. */
138
+ async function expectNoCharge(
139
+ chain: CardChainLink[],
140
+ config?: CardTokenizationConfig,
141
+ ): Promise<void> {
142
+ render(<Harness chain={chain} config={config} />);
143
+ await waitFor(() => {
144
+ expect(client.listSavedCards).toHaveBeenCalled();
145
+ });
146
+ fireEvent.click(screen.getByTestId("fill"));
147
+ fireEvent.click(screen.getByTestId("pay"));
148
+ // The refusal is what ends the submit, so waiting for it is what proves the
149
+ // charge was skipped rather than merely still in flight.
150
+ await waitFor(() => {
151
+ expect(screen.getByTestId("pay-error").textContent).toMatch(/indisponível nesta loja/i);
152
+ });
153
+ expect(client.chargeCard).not.toHaveBeenCalled();
154
+ }
155
+
156
+ beforeEach(() => {
157
+ vi.clearAllMocks();
158
+ client.listSavedCards.mockResolvedValue([]);
159
+ // The head is PagBank with no key, so the hook's on-demand key refresh runs
160
+ // on mount (FUT-174). It finds none here — stub mode is what mints the token.
161
+ client.refreshCardPublicKey.mockResolvedValue({ ok: true, data: { publicKey: null } });
162
+ client.pollOrderStatus.mockResolvedValue({ ok: true, data: "AWAITING_PAYMENT" });
163
+ client.chargeCard.mockResolvedValue({ ok: true, data: { status: "AWAITING_PAYMENT" } });
164
+ });
165
+
166
+ afterEach(() => {
167
+ cleanup();
168
+ });
169
+
170
+ describe("one instrument per provider (FUT-563)", () => {
171
+ it("mints for EVERY entry of the chain from one typed card", async () => {
172
+ await payWith([HEAD, TAIL]);
173
+
174
+ const minted = sentInstruments();
175
+ expect(Object.keys(minted ?? {}).sort()).toEqual(["pagbank", "stone"]);
176
+ // Distinct instruments: one blob reused across providers is the exact
177
+ // thing the gateway refuses to send.
178
+ expect(minted?.["pagbank"]).not.toBe(minted?.["stone"]);
179
+ // The buyer typed once.
180
+ expect(client.chargeCard).toHaveBeenCalledTimes(1);
181
+ });
182
+
183
+ it("keeps the head's own token as the bare `token`", async () => {
184
+ await payWith([HEAD, TAIL]);
185
+
186
+ const input = client.chargeCard.mock.calls[0]?.[0] as { token: string };
187
+ expect(sentInstruments()?.["pagbank"]).toBe(input.token);
188
+ });
189
+
190
+ it("still pays when a TAIL provider cannot be tokenized for", async () => {
191
+ // That provider is one the walk will skip — strictly better than failing
192
+ // the whole payment because the second acquirer's key was missing. The map
193
+ // still travels, naming who DID mint: the server then skips the tail by
194
+ // name instead of reading the bare token as the head's and skipping
195
+ // everyone else.
196
+ await payWith([HEAD, UNMINTABLE]);
197
+
198
+ expect(sentInstruments()).toEqual({ pagbank: expect.any(String) });
199
+ expect(client.chargeCard).toHaveBeenCalledTimes(1);
200
+ });
201
+
202
+ it("sends the map when the TAIL is a provider that needs NO instrument", async () => {
203
+ // The two-provider shape this feature exists for: a card acquirer plus a
204
+ // hosted-page fallback. Nothing is minted for the hosted entry — its own
205
+ // site takes the card — so a map counted by minted keys would be dropped,
206
+ // the bare token read as the head's, and the fallback refused for
207
+ // "holding someone else's instrument". The chain has TWO entries, so the
208
+ // map goes.
209
+ await payWith([HEAD, HOSTED]);
210
+
211
+ expect(sentInstruments()).toEqual({ pagbank: expect.any(String) });
212
+ // And never a mocked one for the hosted provider.
213
+ expect(sentInstruments()).not.toHaveProperty("infinitepay");
214
+ });
215
+
216
+ it("mints against the first MINTABLE entry when the HEAD is a hosted page", async () => {
217
+ // The store this feature creates by simply enabling a second provider:
218
+ // InfinitePay (REDIRECT) first, an acquirer behind it. The server asks the
219
+ // WHOLE chain whether anybody tokenizes in the browser, so it shows our
220
+ // card form — and the submit must mint against the entry the form is being
221
+ // shown FOR. Minting against the head refused the payment outright, with a
222
+ // full PAN already typed and no provider ever asked.
223
+ await payWith([HOSTED, HEAD], HOSTED_HEAD);
224
+
225
+ expect(client.chargeCard).toHaveBeenCalledTimes(1);
226
+ expect(sentInstruments()).toEqual({ pagbank: expect.any(String) });
227
+ // The bare token is the one that was actually minted, not the head's.
228
+ const input = client.chargeCard.mock.calls[0]?.[0] as { token: string };
229
+ expect(input.token).toBe(sentInstruments()?.["pagbank"]);
230
+ });
231
+
232
+ it("still refuses when NO entry of the chain can be minted for", async () => {
233
+ // Nothing to fall back to — the buyer is told so before any charge, which
234
+ // is the honest answer and the one FUT-697 wrote.
235
+ await expectNoCharge([HOSTED, UNMINTABLE], HOSTED_HEAD);
236
+ });
237
+
238
+ it("sends no map at all for a single-provider store", async () => {
239
+ // The pre-FUT-563 wire shape, unchanged: one token, no map.
240
+ await payWith([HEAD]);
241
+
242
+ expect(sentInstruments()).toBeUndefined();
243
+ });
244
+ });
@@ -0,0 +1,99 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * FUT-563 — a BACKUP acquirer nobody can reach must not hold the payment.
4
+ *
5
+ * Minting one instrument per chain entry puts the buyer's Pagar button behind
6
+ * every acquirer in the chain, and a tokenizer is a cross-origin POST with no
7
+ * deadline of its own — browser `fetch` has none either. A middlebox that
8
+ * accepts the socket and never answers therefore left "Pagar R$ …" spinning
9
+ * and disabled for as long as the OS kept the connection: the failover feature
10
+ * blocking on the provider it exists to fall back TO, with the head's own
11
+ * token already minted and nothing wrong with it.
12
+ *
13
+ * These are the halves the browser journey cannot stage — it has no way to
14
+ * make an acquirer stop answering.
15
+ */
16
+ import { afterEach, describe, expect, it, vi } from "vitest";
17
+
18
+ import { resolveNewCardToken } from "../card-instruments";
19
+ import type { CardChainLink } from "../method-capability";
20
+
21
+ vi.mock("../client", () => ({ refreshCardPublicKey: vi.fn() }));
22
+
23
+ const CARD = { number: "4111111111111111", holder: "VERA CADEIA", expiry: "12/34", cvv: "123" };
24
+
25
+ /** Stub mode: the head mints locally, with no network and no key. */
26
+ const HEAD: CardChainLink = {
27
+ provider: "pagbank",
28
+ publicKey: null,
29
+ mockTokenization: true,
30
+ mintable: true,
31
+ };
32
+ /** A real Pagar.me tokenizer — this one goes to the network. */
33
+ const STONE: CardChainLink = {
34
+ provider: "stone",
35
+ publicKey: "pk_stone",
36
+ mockTokenization: false,
37
+ mintable: true,
38
+ };
39
+ /** A real Stripe tokenizer — likewise. */
40
+ const STRIPE: CardChainLink = {
41
+ provider: "stripe",
42
+ publicKey: "pk_stripe",
43
+ mockTokenization: false,
44
+ mintable: true,
45
+ };
46
+
47
+ /** A tokenizer endpoint that accepts the request and never answers. */
48
+ function blackHole(): ReturnType<typeof vi.fn> {
49
+ const fetchMock = vi.fn(() => new Promise<Response>(() => undefined));
50
+ vi.stubGlobal("fetch", fetchMock);
51
+ return fetchMock;
52
+ }
53
+
54
+ afterEach(() => {
55
+ vi.unstubAllGlobals();
56
+ });
57
+
58
+ describe("minting the chain is bounded and concurrent", () => {
59
+ it("charges on the head when a TAIL acquirer never answers", async () => {
60
+ const fetchMock = blackHole();
61
+
62
+ const resolved = await resolveNewCardToken(CARD, HEAD, "o1", vi.fn(), false, [HEAD, STONE], 20);
63
+
64
+ // The degradation this module already documents: the provider we could not
65
+ // mint for is one the walk will skip. The payment still goes out.
66
+ expect(resolved.ok).toBe(true);
67
+ const instruments = resolved.ok ? resolved.data : null;
68
+ expect(instruments?.tokensByProvider).toEqual({ pagbank: expect.any(String) });
69
+ // And the abandoned request is ABORTED, not merely ignored — a token that
70
+ // arrives after we gave up on it is one nothing will ever charge.
71
+ const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
72
+ expect(init?.signal?.aborted).toBe(true);
73
+ });
74
+
75
+ it("asks every tail at ONCE, not one after the other", async () => {
76
+ // Sequentially, two unreachable backups cost the buyer two full deadlines
77
+ // before a charge they could have had immediately.
78
+ const fetchMock = blackHole();
79
+ const deadlineMs = 400;
80
+
81
+ const minting = resolveNewCardToken(
82
+ CARD,
83
+ HEAD,
84
+ "o1",
85
+ vi.fn(),
86
+ false,
87
+ [HEAD, STONE, STRIPE],
88
+ deadlineMs,
89
+ );
90
+ // Both requests are in flight well inside ONE deadline — which they could
91
+ // not be if the second waited for the first to time out.
92
+ await vi.waitFor(() => {
93
+ expect(fetchMock).toHaveBeenCalledTimes(2);
94
+ });
95
+
96
+ const resolved = await minting;
97
+ expect(resolved.ok).toBe(true);
98
+ });
99
+ });
@@ -0,0 +1,161 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * FUT-563 — the browser mints ONE instrument PER PROVIDER, so a card charge
4
+ * can walk the merchant's chain.
5
+ *
6
+ * A card token is bound to whoever minted it: the gateway refuses to hand
7
+ * provider #2 provider #1's blob and skips it instead. So the checkout's whole
8
+ * contribution to failover is this — take the card the buyer typed ONCE, and
9
+ * produce an instrument for each entry of the chain the server published.
10
+ *
11
+ * The scenarios the browser journey cannot reach live here: a chain entry with
12
+ * no in-browser scheme (a hosted page needs no instrument), a chain the host
13
+ * never published (an older server, or a fetch blip), and an entry whose
14
+ * tokenization fails while the others succeed.
15
+ */
16
+ import { describe, expect, it } from "vitest";
17
+
18
+ import { cardChain, cardPathAvailable, cardTokenization } from "../method-capability";
19
+ import type { CheckoutProviderConfig } from "../types";
20
+
21
+ function link(
22
+ provider: string,
23
+ tokenization: CheckoutProviderConfig["tokenization"],
24
+ publicKey: string | null = null,
25
+ ) {
26
+ return {
27
+ provider,
28
+ tokenization: tokenization as "NONE" | "PUBLIC_KEY" | "SDK" | "REDIRECT",
29
+ publicKey,
30
+ mockTokenization: false,
31
+ methods: ["PIX" as const, "CARD" as const],
32
+ };
33
+ }
34
+
35
+ function config(over: Partial<CheckoutProviderConfig> = {}): CheckoutProviderConfig {
36
+ return {
37
+ provider: "pagbank",
38
+ tokenization: "PUBLIC_KEY",
39
+ publicKey: "pk_pagbank",
40
+ mockTokenization: false,
41
+ methods: ["PIX", "CARD"],
42
+ ...over,
43
+ };
44
+ }
45
+
46
+ describe("cardChain", () => {
47
+ it("gives one tokenization config per entry, in the merchant's order", async () => {
48
+ const chain = cardChain(
49
+ config({
50
+ chain: [link("pagbank", "PUBLIC_KEY", "pk_pagbank"), link("stone", "PUBLIC_KEY", "pk_stone")],
51
+ }),
52
+ );
53
+
54
+ expect(chain.map((entry) => entry.provider)).toEqual(["pagbank", "stone"]);
55
+ expect(chain.map((entry) => entry.publicKey)).toEqual(["pk_pagbank", "pk_stone"]);
56
+ });
57
+
58
+ it("marks entries with no in-browser scheme unmintable instead of dropping them", async () => {
59
+ // A REDIRECT provider's own page takes the card, and a NONE one asks for no
60
+ // instrument. Minting for either would produce a fake token under stub mode
61
+ // and an error everywhere else — and the gateway passes an unattributed
62
+ // charge straight through to them anyway.
63
+ //
64
+ // They stay in the LIST because the charge walks them: the card path counts
65
+ // this list to decide whether `tokensByProvider` has to travel at all, and
66
+ // a chain counted by "who minted" hides the hosted page from the server.
67
+ const chain = cardChain(
68
+ config({
69
+ chain: [
70
+ link("pagbank", "PUBLIC_KEY", "pk_pagbank"),
71
+ link("infinitepay", "REDIRECT"),
72
+ link("nothing", "NONE"),
73
+ ],
74
+ }),
75
+ );
76
+
77
+ expect(chain.map((entry) => entry.provider)).toEqual(["pagbank", "infinitepay", "nothing"]);
78
+ expect(chain.map((entry) => entry.mintable)).toEqual([true, false, false]);
79
+ });
80
+
81
+ it("falls back to the HEAD alone when the host published no chain", async () => {
82
+ // Exactly the pre-FUT-563 behaviour: one instrument, for the active
83
+ // provider. A server that does not answer a chain must not break checkout.
84
+ expect(cardChain(config())).toEqual([{ ...cardTokenization(config()), mintable: true }]);
85
+ });
86
+
87
+ it("has nothing to mint for a store with no provider at all", async () => {
88
+ expect(cardChain(null)).toEqual([]);
89
+ });
90
+
91
+ it("never re-heads or reorders the merchant's list", async () => {
92
+ // The storefront is a READER of the priority list. Preferring the entry
93
+ // with a key, or sorting by anything, would silently override the order the
94
+ // owner set and the plan ceiling already truncated.
95
+ const chain = cardChain(
96
+ config({
97
+ provider: "stone",
98
+ chain: [link("stone", "PUBLIC_KEY", null), link("pagbank", "PUBLIC_KEY", "pk_pagbank")],
99
+ }),
100
+ );
101
+
102
+ expect(chain.map((entry) => entry.provider)).toEqual(["stone", "pagbank"]);
103
+ });
104
+
105
+ it("counts the whole chain, hosted entries included", async () => {
106
+ // What `tokensByProvider`'s presence is decided on. A card acquirer plus a
107
+ // hosted-page fallback is TWO providers the walk may reach, even though
108
+ // only one of them is ever minted for.
109
+ const chain = cardChain(
110
+ config({ chain: [link("pagbank", "PUBLIC_KEY", "pk_pagbank"), link("infinitepay", "REDIRECT")] }),
111
+ );
112
+
113
+ expect(chain).toHaveLength(2);
114
+ expect(chain.filter((entry) => entry.mintable)).toHaveLength(1);
115
+ });
116
+ });
117
+
118
+ /**
119
+ * FUT-563 — the picker and the SERVER must answer the same question.
120
+ *
121
+ * `usesHostedCheckout` asks the WHOLE chain whether anybody tokenizes in the
122
+ * browser, so a REDIRECT head no longer means "the buyer is handed over". A
123
+ * client that still read the head alone offered a card the submit could not
124
+ * mint, and a store whose only mintable entry has no key offered one nobody
125
+ * could pay.
126
+ */
127
+ describe("cardPathAvailable", () => {
128
+ it("offers CARD for a hosted-page store: its own site takes the card", async () => {
129
+ expect(cardPathAvailable(config({ chain: [link("infinitepay", "REDIRECT")] }))).toBe(true);
130
+ });
131
+
132
+ it("offers CARD when a LATER entry can be minted for, not just the head", async () => {
133
+ // The server shows our form for exactly this chain, so the picker must
134
+ // agree — and the submit mints against the tail.
135
+ const mixed = config({
136
+ provider: "infinitepay",
137
+ tokenization: "REDIRECT",
138
+ publicKey: null,
139
+ chain: [link("infinitepay", "REDIRECT"), link("pagbank", "PUBLIC_KEY", "pk_pagbank")],
140
+ });
141
+
142
+ expect(cardPathAvailable(mixed)).toBe(true);
143
+ });
144
+
145
+ it("refuses CARD when the only mintable entry has no key this browser can use", async () => {
146
+ // Stone with no publishable key: the form would render and the submit
147
+ // would die. Better a disabled tile that says so.
148
+ const unusable = config({
149
+ provider: "infinitepay",
150
+ tokenization: "REDIRECT",
151
+ publicKey: null,
152
+ chain: [link("infinitepay", "REDIRECT"), link("stone", "PUBLIC_KEY", null)],
153
+ });
154
+
155
+ expect(cardPathAvailable(unusable)).toBe(false);
156
+ });
157
+
158
+ it("still fails OPEN while the config has not answered", async () => {
159
+ expect(cardPathAvailable(null)).toBe(true);
160
+ });
161
+ });