@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 +4 -4
- package/src/card/stripe-token.ts +3 -0
- package/src/card/tokenize.ts +17 -3
- package/src/components/checkout/__tests__/chain-instruments.test.tsx +244 -0
- package/src/components/checkout/__tests__/mint-deadline.test.ts +99 -0
- package/src/components/checkout/__tests__/provider-chain.test.ts +161 -0
- package/src/components/checkout/__tests__/unresolved-charge.test.tsx +155 -0
- package/src/components/checkout/card-instruments.ts +222 -0
- package/src/components/checkout/card-view.tsx +56 -6
- package/src/components/checkout/checkout-flow.tsx +1 -0
- package/src/components/checkout/checkout-steps.tsx +13 -54
- package/src/components/checkout/client.ts +21 -3
- package/src/components/checkout/failure-codes.ts +17 -0
- package/src/components/checkout/method-capability.ts +83 -9
- package/src/components/checkout/payment-error-panel.tsx +97 -0
- package/src/components/checkout/types.ts +39 -4
- package/src/components/checkout/use-card-checkout.ts +77 -91
- package/src/components/checkout/use-checkout-controller.ts +33 -6
- package/src/result.ts +15 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
32
|
-
"@12-apps/
|
|
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",
|
package/src/card/stripe-token.ts
CHANGED
|
@@ -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.");
|
package/src/card/tokenize.ts
CHANGED
|
@@ -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
|
+
});
|