@12-apps/payments-frontend 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -5
- package/src/card/stripe-token.ts +3 -0
- package/src/card/tokenize.ts +17 -3
- 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/eslint.config.js +0 -34
- package/src/__tests__/checkout-confirmation.test.tsx +0 -177
- package/src/__tests__/connection-state.test.tsx +0 -84
- package/src/__tests__/context.test.tsx +0 -88
- package/src/__tests__/controlled-provider.test.tsx +0 -116
- package/src/__tests__/credential-confirm.test.tsx +0 -193
- package/src/__tests__/initial-provider.test.tsx +0 -81
- package/src/__tests__/provider-priority-list.test.tsx +0 -159
- package/src/__tests__/provider-status-bar.test.tsx +0 -152
- package/src/__tests__/slugged-provider.test.tsx +0 -108
- package/src/__tests__/verification-slot.test.tsx +0 -125
- package/src/card/tokenize.test.ts +0 -194
- package/src/components/checkout/__tests__/card-3ds-handover.test.tsx +0 -147
- package/src/components/checkout/__tests__/clear-cart-on-paid.test.tsx +0 -64
- package/src/components/checkout/__tests__/hosted-return.test.ts +0 -109
- package/src/components/checkout/__tests__/method-capability.test.tsx +0 -120
- package/src/components/checkout/__tests__/payments-unavailable.test.tsx +0 -53
- package/src/components/checkout/__tests__/save-on-continue.test.tsx +0 -165
- package/src/components/checkout/__tests__/second-host.test.tsx +0 -86
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
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,7 +15,7 @@
|
|
|
15
15
|
"typecheck": "tsc --noEmit"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@12-apps/payments-backend": "^1.
|
|
18
|
+
"@12-apps/payments-backend": "^1.4.1",
|
|
19
19
|
"react-qr-code": "^2.2.0"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"@emotion/react": "^11.14.0",
|
|
30
30
|
"@emotion/styled": "^11.14.0",
|
|
31
31
|
"@mui/material": "^6.5.0",
|
|
32
|
-
"@12-apps/eslint-config": "^1.
|
|
33
|
-
"@12-apps/typescript-config": "^1.
|
|
32
|
+
"@12-apps/eslint-config": "^1.5.1",
|
|
33
|
+
"@12-apps/typescript-config": "^1.5.1",
|
|
34
34
|
"@testing-library/react": "^16.1.0",
|
|
35
35
|
"@types/react": "19.2.2",
|
|
36
36
|
"@types/react-dom": "19.2.2",
|
|
@@ -61,6 +61,14 @@
|
|
|
61
61
|
"prisma",
|
|
62
62
|
"*.js",
|
|
63
63
|
"*.mjs",
|
|
64
|
-
"*.md"
|
|
64
|
+
"*.md",
|
|
65
|
+
"!eslint.config.js",
|
|
66
|
+
"!**/__tests__/**",
|
|
67
|
+
"!**/tests/**",
|
|
68
|
+
"!**/*.test.*",
|
|
69
|
+
"!**/*.spec.*",
|
|
70
|
+
"!**/*.stories.*",
|
|
71
|
+
"!**/*.test-story.*",
|
|
72
|
+
"!**/test-helpers.*"
|
|
65
73
|
]
|
|
66
74
|
}
|
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,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
tokenizeForCheckout,
|
|
3
|
+
tokenizerFor,
|
|
4
|
+
type CardDetails,
|
|
5
|
+
type CardToken,
|
|
6
|
+
type CardTokenizationConfig,
|
|
7
|
+
} from "../../card";
|
|
8
|
+
import { err, ok, type Result } from "../../result";
|
|
9
|
+
|
|
10
|
+
import { refreshCardPublicKey } from "./client";
|
|
11
|
+
import type { CardChainLink } from "./method-capability";
|
|
12
|
+
import type { SavedCardMeta } from "./types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* TOKENIZATION for the buyer's card — one instrument per provider the charge
|
|
16
|
+
* may reach (FUT-563).
|
|
17
|
+
*
|
|
18
|
+
* A card token is bound to whoever minted it: the gateway refuses to hand
|
|
19
|
+
* provider #2 provider #1's blob and skips it instead. So the checkout's whole
|
|
20
|
+
* contribution to card failover is here — take the card the buyer typed ONCE,
|
|
21
|
+
* and produce an instrument for each entry of the chain the server published.
|
|
22
|
+
*
|
|
23
|
+
* Split out of `use-card-checkout.ts` so the hook stays about SUBMIT state:
|
|
24
|
+
* nothing in this module touches React.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tokenize a new card in the ACTIVE provider's protocol (FUT-697), self-healing
|
|
29
|
+
* a rotated public key (FUT-174): the card has already passed local validation,
|
|
30
|
+
* so a real-key encryption failure most likely means the store's key rotated.
|
|
31
|
+
* Refresh the store's key once and retry before surfacing the error;
|
|
32
|
+
* `onKeyRefreshed` caches the new key for the session. The refresh is scoped to
|
|
33
|
+
* the buyer's OWN `orderId` (the route derives the store from it server-side),
|
|
34
|
+
* never a client-supplied store id — and only PagBank can mint a key on demand,
|
|
35
|
+
* so the self-heal is gated on its scheme.
|
|
36
|
+
*/
|
|
37
|
+
async function tokenizeNewCard(
|
|
38
|
+
card: CardDetails,
|
|
39
|
+
config: CardTokenizationConfig,
|
|
40
|
+
orderId: string,
|
|
41
|
+
onKeyRefreshed: (key: string) => void,
|
|
42
|
+
): Promise<Result<CardToken>> {
|
|
43
|
+
const first = await tokenizeForCheckout(card, config);
|
|
44
|
+
if (first.ok || !config.publicKey) return first;
|
|
45
|
+
if (config.provider === null || tokenizerFor(config.provider) !== "pagbank-sdk") return first;
|
|
46
|
+
|
|
47
|
+
const refreshed = await refreshCardPublicKey({ orderId });
|
|
48
|
+
if (refreshed.ok && refreshed.data.publicKey && refreshed.data.publicKey !== config.publicKey) {
|
|
49
|
+
onKeyRefreshed(refreshed.data.publicKey);
|
|
50
|
+
return tokenizeForCheckout(card, { ...config, publicKey: refreshed.data.publicKey });
|
|
51
|
+
}
|
|
52
|
+
return first;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Non-sensitive display metadata for saving a card (the PAN never leaves the form). */
|
|
56
|
+
function toCardMeta(card: CardDetails, token: CardToken): SavedCardMeta {
|
|
57
|
+
const [mm = "", yy = ""] = card.expiry.split("/");
|
|
58
|
+
return {
|
|
59
|
+
brand: token.brand,
|
|
60
|
+
last4: token.last4,
|
|
61
|
+
expMonth: Number(mm),
|
|
62
|
+
expYear: 2000 + Number(yy),
|
|
63
|
+
holder: card.holder.trim(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One instrument per provider in the chain (FUT-563), keyed by provider name.
|
|
69
|
+
*
|
|
70
|
+
* A card token is bound to whoever minted it, so a charge can only fail over
|
|
71
|
+
* onto a provider the browser ALSO tokenized for. Every entry is attempted and
|
|
72
|
+
* the failures are simply left out: a provider we could not mint for is one the
|
|
73
|
+
* walk will skip, which is the honest outcome and strictly better than failing
|
|
74
|
+
* the whole payment because the second acquirer's key was missing.
|
|
75
|
+
*
|
|
76
|
+
* Nothing is re-typed and nothing is asked of the buyer twice — the same
|
|
77
|
+
* validated card fields are encrypted once per provider, in the browser.
|
|
78
|
+
*/
|
|
79
|
+
async function mintChainInstruments(
|
|
80
|
+
card: CardDetails,
|
|
81
|
+
chain: readonly CardChainLink[],
|
|
82
|
+
timeoutMs: number,
|
|
83
|
+
): Promise<Record<string, CardToken>> {
|
|
84
|
+
const results = await Promise.all(
|
|
85
|
+
chain.map(async (link) => {
|
|
86
|
+
// A hosted page (`REDIRECT`) or an instrument-free provider (`NONE`) is
|
|
87
|
+
// skipped, never mocked: minting for it would produce a FAKE token under
|
|
88
|
+
// stub mode and an error everywhere else. It still travels in `chain`,
|
|
89
|
+
// because the walk will reach it.
|
|
90
|
+
if (!link.provider || !link.mintable) return null;
|
|
91
|
+
const tokenized = await mintWithDeadline(card, link, timeoutMs);
|
|
92
|
+
return tokenized.ok ? ([link.provider, tokenized.data] as const) : null;
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
return Object.fromEntries(results.filter((entry) => entry !== null));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* How long ONE backup acquirer may hold the buyer's Pagar button.
|
|
100
|
+
*
|
|
101
|
+
* A tokenizer is a cross-origin POST to the acquirer (Pagar.me, Stripe) with
|
|
102
|
+
* no deadline of its own, and browser `fetch` has none either: a middlebox that
|
|
103
|
+
* accepts the socket and never answers leaves the promise pending for as long
|
|
104
|
+
* as the OS keeps the connection, which the buyer sees as a spinning, disabled
|
|
105
|
+
* "Pagar R$ …" with no cancel and no explanation. Bounding it degrades to what
|
|
106
|
+
* this module already documents — a provider we could not mint for is one the
|
|
107
|
+
* walk will skip.
|
|
108
|
+
*/
|
|
109
|
+
const MINT_TIMEOUT_MS = 8_000;
|
|
110
|
+
|
|
111
|
+
/** One tail mint, abandoned (and aborted) when the deadline passes. */
|
|
112
|
+
async function mintWithDeadline(
|
|
113
|
+
card: CardDetails,
|
|
114
|
+
link: CardChainLink,
|
|
115
|
+
timeoutMs: number,
|
|
116
|
+
): Promise<Result<CardToken>> {
|
|
117
|
+
const controller = new AbortController();
|
|
118
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
119
|
+
// RACED as well as aborted: the abort ends a `fetch`, but the PagBank scheme
|
|
120
|
+
// waits on an injected <script> that can hang with nothing to cancel.
|
|
121
|
+
const deadline = new Promise<Result<CardToken>>((resolve) => {
|
|
122
|
+
timer = setTimeout(() => {
|
|
123
|
+
controller.abort();
|
|
124
|
+
resolve(err("O provedor do cartão não respondeu a tempo."));
|
|
125
|
+
}, timeoutMs);
|
|
126
|
+
});
|
|
127
|
+
try {
|
|
128
|
+
return await Promise.race([tokenizeForCheckout(card, link, controller.signal), deadline]);
|
|
129
|
+
} finally {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What a submitted card charge carries: the head's token plus the chain's. */
|
|
135
|
+
export interface CardInstruments {
|
|
136
|
+
token: string;
|
|
137
|
+
tokensByProvider?: Record<string, string>;
|
|
138
|
+
cardMeta?: SavedCardMeta;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The chain entry the bare `token` is minted from — NOT the chain head.
|
|
143
|
+
*
|
|
144
|
+
* `usesHostedCheckout` asks the WHOLE chain whether anybody tokenizes in the
|
|
145
|
+
* browser, so a store headed by a hosted page whose NEXT provider does gets our
|
|
146
|
+
* card form rather than the handover. The head then has no in-browser scheme at
|
|
147
|
+
* all: minting against it returns "o pagamento com cartão está indisponível",
|
|
148
|
+
* and the buyer who just typed a full PAN is refused while the mintable tail —
|
|
149
|
+
* the very provider the form was shown for — is never asked. That is the whole
|
|
150
|
+
* card path of every REDIRECT-headed store that adds a backup provider, and
|
|
151
|
+
* enabling a provider APPENDS it, so the shape arises with no reordering.
|
|
152
|
+
*
|
|
153
|
+
* The ACTIVE provider still wins when it is itself mintable: only its config
|
|
154
|
+
* carries the key this session may have self-healed (FUT-174).
|
|
155
|
+
*
|
|
156
|
+
* Falls back to the active config when nothing in the chain can be minted for
|
|
157
|
+
* — the tokenizer then says so in the buyer's own words, which is the honest
|
|
158
|
+
* answer for a store that has no in-browser card path at all.
|
|
159
|
+
*/
|
|
160
|
+
function mintingConfig(
|
|
161
|
+
config: CardTokenizationConfig,
|
|
162
|
+
chain: readonly CardChainLink[],
|
|
163
|
+
): CardTokenizationConfig {
|
|
164
|
+
const mintable = chain.filter((link) => link.mintable && link.provider);
|
|
165
|
+
if (mintable.length === 0) return config;
|
|
166
|
+
return mintable.some((link) => link.provider === config.provider) ? config : mintable[0]!;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The charge token for a new card (tokenize + self-heal), plus optional save-meta. */
|
|
170
|
+
export async function resolveNewCardToken(
|
|
171
|
+
card: CardDetails,
|
|
172
|
+
config: CardTokenizationConfig,
|
|
173
|
+
orderId: string,
|
|
174
|
+
onKeyRefreshed: (key: string) => void,
|
|
175
|
+
saveCard: boolean,
|
|
176
|
+
chain: readonly CardChainLink[],
|
|
177
|
+
/** Per-entry mint deadline. Overridable so tests need not wait it out. */
|
|
178
|
+
timeoutMs: number = MINT_TIMEOUT_MS,
|
|
179
|
+
): Promise<Result<CardInstruments>> {
|
|
180
|
+
// No chain served (an older host, or a fetch blip): the active provider
|
|
181
|
+
// alone, exactly the pre-FUT-563 behaviour.
|
|
182
|
+
const entries = chain.length > 0 ? chain : [{ ...config, mintable: true }];
|
|
183
|
+
const head = mintingConfig(config, entries);
|
|
184
|
+
// The self-heal rides along and stays PagBank-only — `tokenizeNewCard` gates
|
|
185
|
+
// on its scheme, so a chain headed elsewhere cannot ask for someone's key.
|
|
186
|
+
// Head and tail mint CONCURRENTLY. Sequentially, one unreachable backup
|
|
187
|
+
// acquirer held a healthy head's charge for as long as the network stack
|
|
188
|
+
// allowed — the failover feature blocking on the provider it exists to fall
|
|
189
|
+
// back to. The head keeps no deadline: it is the provider being paid, and
|
|
190
|
+
// its self-heal is a second round trip of our own.
|
|
191
|
+
const rest = entries.filter((link) => link.provider !== head.provider);
|
|
192
|
+
const [headToken, tail] = await Promise.all([
|
|
193
|
+
tokenizeNewCard(card, head, orderId, onKeyRefreshed),
|
|
194
|
+
mintChainInstruments(card, rest, timeoutMs),
|
|
195
|
+
]);
|
|
196
|
+
// A failure in the tail is not fatal: that provider is simply one the walk
|
|
197
|
+
// will skip.
|
|
198
|
+
const minted = { ...tail };
|
|
199
|
+
if (headToken.ok && head.provider) minted[head.provider] = headToken.data;
|
|
200
|
+
|
|
201
|
+
// Refused only when NO entry could be minted for. While one still can, the
|
|
202
|
+
// charge goes out and the entries we hold nothing for are skipped by name.
|
|
203
|
+
const anyMinted = Object.values(minted);
|
|
204
|
+
if (!headToken.ok && anyMinted.length === 0) return headToken;
|
|
205
|
+
const primary = headToken.ok ? headToken.data : anyMinted[0]!;
|
|
206
|
+
const tokensByProvider = Object.fromEntries(
|
|
207
|
+
Object.entries(minted).map(([provider, instrument]) => [provider, instrument.token]),
|
|
208
|
+
);
|
|
209
|
+
return ok({
|
|
210
|
+
token: primary.token,
|
|
211
|
+
// Sent whenever the WALK has more than one provider to reach — counted on
|
|
212
|
+
// the chain the server published, never on how many instruments happened
|
|
213
|
+
// to be minted (FUT-563). A hosted-page provider mints nothing by design,
|
|
214
|
+
// so counting the map drops it for the two-provider store it exists for:
|
|
215
|
+
// the bare token is then read as the chain HEAD's and every other entry is
|
|
216
|
+
// refused as "holding someone else's instrument", including the one that
|
|
217
|
+
// needed none. A genuinely single-provider store still sends exactly what
|
|
218
|
+
// it sent before.
|
|
219
|
+
...(entries.length > 1 ? { tokensByProvider } : {}),
|
|
220
|
+
...(saveCard ? { cardMeta: toCardMeta(card, primary) } : {}),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
type CardTokenizationConfig,
|
|
9
9
|
} from "../../card";
|
|
10
10
|
|
|
11
|
+
import { UNRESOLVED_CODE } from "./failure-codes";
|
|
12
|
+
import type { CardChainLink } from "./method-capability";
|
|
11
13
|
import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
|
|
12
14
|
import { useCheckoutComponents } from "./ui";
|
|
13
15
|
import { useCardCheckout } from "./use-card-checkout";
|
|
@@ -58,6 +60,38 @@ function SubmittedState({
|
|
|
58
60
|
);
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
/**
|
|
64
|
+
* What a submit came back with.
|
|
65
|
+
*
|
|
66
|
+
* An UNRESOLVED charge gets its own presentation (FUT-563): some provider may
|
|
67
|
+
* be holding the buyer's money, so wording it as a failure — and heading it
|
|
68
|
+
* "Não foi possível pagar" above a body that says "não pague de novo" — pushes
|
|
69
|
+
* the buyer toward exactly the second payment it forbids.
|
|
70
|
+
*/
|
|
71
|
+
function ChargeFailure({
|
|
72
|
+
message,
|
|
73
|
+
unresolved,
|
|
74
|
+
}: {
|
|
75
|
+
message: string;
|
|
76
|
+
unresolved: boolean;
|
|
77
|
+
}): JSX.Element {
|
|
78
|
+
const { Alert } = useCheckoutComponents();
|
|
79
|
+
if (unresolved) {
|
|
80
|
+
return (
|
|
81
|
+
<Alert
|
|
82
|
+
variant="warning"
|
|
83
|
+
title="Estamos confirmando seu pagamento"
|
|
84
|
+
description={message}
|
|
85
|
+
showIcon
|
|
86
|
+
data-testid="card-unresolved"
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return (
|
|
91
|
+
<Alert variant="danger" title="Não foi possível pagar" description={message} showIcon data-testid="card-error" />
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
61
95
|
/**
|
|
62
96
|
* Card payment view (FUT-58). Card data is validated + formatted client-side, then
|
|
63
97
|
* tokenized (mock PagBank JS SDK) so the PAN never reaches our server; only the
|
|
@@ -72,6 +106,7 @@ export function CardView({
|
|
|
72
106
|
order,
|
|
73
107
|
buyer = {},
|
|
74
108
|
providerConfig,
|
|
109
|
+
providerChain,
|
|
75
110
|
tenantSlug,
|
|
76
111
|
onResolved,
|
|
77
112
|
pollIntervalMs = 2500,
|
|
@@ -80,13 +115,24 @@ export function CardView({
|
|
|
80
115
|
buyer?: BuyerInfo;
|
|
81
116
|
/** The active provider's tokenization protocol + key (FUT-697). */
|
|
82
117
|
providerConfig: CardTokenizationConfig;
|
|
118
|
+
/**
|
|
119
|
+
* Every provider the charge may WALK, in the merchant's order (FUT-563) —
|
|
120
|
+
* one instrument is minted per entry so the charge survives the first
|
|
121
|
+
* provider failing. Omitted ⇒ the head alone, as before.
|
|
122
|
+
*/
|
|
123
|
+
providerChain?: CardChainLink[];
|
|
83
124
|
/** Scopes the saved-card list to cards the store's provider can charge. */
|
|
84
125
|
tenantSlug?: string;
|
|
85
126
|
onResolved: (status: OrderStatus) => void;
|
|
86
127
|
pollIntervalMs?: number;
|
|
87
128
|
}): JSX.Element {
|
|
88
|
-
const {
|
|
89
|
-
const cc = useCardCheckout(order, buyer, providerConfig, onResolved, pollIntervalMs, tenantSlug);
|
|
129
|
+
const { Text } = useCheckoutComponents();
|
|
130
|
+
const cc = useCardCheckout(order, buyer, providerConfig, onResolved, pollIntervalMs, tenantSlug, providerChain);
|
|
131
|
+
// A charge NOBODY can confirm yet is not a decline (FUT-563). Some provider
|
|
132
|
+
// may be holding the buyer's money, so it gets its own presentation: the
|
|
133
|
+
// danger heading "Não foi possível pagar" contradicts the body's "não pague
|
|
134
|
+
// de novo" and pushes the buyer toward exactly the retry it forbids.
|
|
135
|
+
const unresolved = cc.errorCode === UNRESOLVED_CODE;
|
|
90
136
|
|
|
91
137
|
if (cc.submitted) {
|
|
92
138
|
return <SubmittedState pollError={cc.pollError} pollTimedOut={cc.pollTimedOut} />;
|
|
@@ -118,11 +164,15 @@ export function CardView({
|
|
|
118
164
|
/>
|
|
119
165
|
) : null}
|
|
120
166
|
|
|
121
|
-
{cc.error ?
|
|
122
|
-
<Alert variant="danger" title="Não foi possível pagar" description={cc.error} showIcon data-testid="card-error" />
|
|
123
|
-
) : null}
|
|
167
|
+
{cc.error ? <ChargeFailure message={cc.error} unresolved={unresolved} /> : null}
|
|
124
168
|
|
|
125
|
-
|
|
169
|
+
{/* The pay bar is GONE while a charge is unresolved, not merely disabled
|
|
170
|
+
with a spinner: paying again is the one action the message forbids,
|
|
171
|
+
and a live "Pagar R$ …" directly under "não pague de novo" is what
|
|
172
|
+
the buyer's thumb reaches for. */}
|
|
173
|
+
{unresolved ? null : (
|
|
174
|
+
<CardPayBar totalLabel={order.totalLabel} submitting={cc.submitting} onPay={() => void cc.handlePay()} />
|
|
175
|
+
)}
|
|
126
176
|
</Box>
|
|
127
177
|
);
|
|
128
178
|
}
|
|
@@ -161,6 +161,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
|
|
|
161
161
|
creating={c.creating}
|
|
162
162
|
createError={c.createError}
|
|
163
163
|
errorField={c.errorField}
|
|
164
|
+
errorCode={c.errorCode}
|
|
164
165
|
onGenerate={(chosen) => void c.startPayment(chosen)}
|
|
165
166
|
onUseEmail={c.payWithEmail}
|
|
166
167
|
// Set only for a skipped-Dados flow (the controller decides); the
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { Box } from "@mui/material";
|
|
2
|
-
import { useEffect, useRef,
|
|
2
|
+
import { useEffect, useRef, type JSX, type ReactNode } from "react";
|
|
3
3
|
|
|
4
4
|
import { BuyerInfoForm } from "./buyer-info-form";
|
|
5
5
|
import { CardView } from "./card-view";
|
|
6
6
|
import { LockOutlinedIcon } from "./icons";
|
|
7
7
|
import {
|
|
8
|
+
cardChain,
|
|
8
9
|
cardPathAvailable,
|
|
9
10
|
cardTokenization,
|
|
10
11
|
offeredMethods,
|
|
11
12
|
usePreselectSoleMethod,
|
|
12
13
|
} from "./method-capability";
|
|
13
14
|
import { MethodPicker } from "./method-picker";
|
|
15
|
+
import { PaymentErrorPanel } from "./payment-error-panel";
|
|
14
16
|
import { PayerSummary } from "./payer-summary";
|
|
15
17
|
import { PixView } from "./pix-view";
|
|
16
18
|
import type {
|
|
@@ -74,6 +76,9 @@ function PaymentBody({
|
|
|
74
76
|
order={order}
|
|
75
77
|
buyer={buyer}
|
|
76
78
|
providerConfig={cardTokenization(providerConfig)}
|
|
79
|
+
// The whole chain (FUT-563): one instrument is minted per provider so
|
|
80
|
+
// the charge survives the first one failing, with nothing re-typed.
|
|
81
|
+
providerChain={cardChain(providerConfig)}
|
|
77
82
|
tenantSlug={tenantSlug}
|
|
78
83
|
onResolved={onResolved}
|
|
79
84
|
pollIntervalMs={pollIntervalMs}
|
|
@@ -229,6 +234,11 @@ interface PaymentStepProps {
|
|
|
229
234
|
creating: boolean;
|
|
230
235
|
createError: string | null;
|
|
231
236
|
errorField: BuyerField | null;
|
|
237
|
+
/**
|
|
238
|
+
* The refusal's machine code (FUT-563). An UNRESOLVED charge is not a failed
|
|
239
|
+
* one — the panel below must not offer to raise a second.
|
|
240
|
+
*/
|
|
241
|
+
errorCode?: string | null;
|
|
232
242
|
onGenerate: (method: PaymentMethod) => void;
|
|
233
243
|
onUseEmail: (email: string) => void;
|
|
234
244
|
/**
|
|
@@ -262,6 +272,7 @@ export function PaymentStep({
|
|
|
262
272
|
creating,
|
|
263
273
|
createError,
|
|
264
274
|
errorField,
|
|
275
|
+
errorCode,
|
|
265
276
|
onGenerate,
|
|
266
277
|
onUseEmail,
|
|
267
278
|
onEditBuyer,
|
|
@@ -304,6 +315,7 @@ export function PaymentStep({
|
|
|
304
315
|
<PaymentErrorPanel
|
|
305
316
|
message={createError}
|
|
306
317
|
emailFlagged={errorField === "email"}
|
|
318
|
+
code={errorCode}
|
|
307
319
|
onUseEmail={onUseEmail}
|
|
308
320
|
onRetry={() => onGenerate(method)}
|
|
309
321
|
/>
|
|
@@ -311,56 +323,3 @@ export function PaymentStep({
|
|
|
311
323
|
</Box>
|
|
312
324
|
);
|
|
313
325
|
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Order-creation failure (non-field) shown inline on Pagamento with a retry — the
|
|
317
|
-
* buyer never leaves the step. When the buyer e-mail was rejected (owner testing
|
|
318
|
-
* with the store's own e-mail) it offers a different e-mail to pay with.
|
|
319
|
-
*/
|
|
320
|
-
function PaymentErrorPanel({
|
|
321
|
-
message,
|
|
322
|
-
emailFlagged,
|
|
323
|
-
onUseEmail,
|
|
324
|
-
onRetry,
|
|
325
|
-
}: {
|
|
326
|
-
message: string;
|
|
327
|
-
emailFlagged: boolean;
|
|
328
|
-
onUseEmail: (email: string) => void;
|
|
329
|
-
onRetry: () => void;
|
|
330
|
-
}): JSX.Element {
|
|
331
|
-
const { Alert, Button, Input } = useCheckoutComponents();
|
|
332
|
-
const [altEmail, setAltEmail] = useState("");
|
|
333
|
-
|
|
334
|
-
return (
|
|
335
|
-
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
336
|
-
<Alert variant="danger" title="Não foi possível continuar" description={message} showIcon data-testid="checkout-error" />
|
|
337
|
-
{emailFlagged ? (
|
|
338
|
-
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
339
|
-
<Input
|
|
340
|
-
label="E-mail para o pagamento"
|
|
341
|
-
type="email"
|
|
342
|
-
variant="outlined"
|
|
343
|
-
size="md"
|
|
344
|
-
fullWidth
|
|
345
|
-
autoComplete="email"
|
|
346
|
-
placeholder="use um e-mail diferente do da loja"
|
|
347
|
-
value={altEmail}
|
|
348
|
-
onChange={(event) => setAltEmail(event.target.value)}
|
|
349
|
-
data-testid="checkout-alt-email"
|
|
350
|
-
/>
|
|
351
|
-
<Box>
|
|
352
|
-
<Button variant="solid" color="primary" size="md" disabled={!altEmail.trim()} onClick={() => onUseEmail(altEmail.trim())} dataTestId="checkout-use-alt-email">
|
|
353
|
-
Usar este e-mail e continuar
|
|
354
|
-
</Button>
|
|
355
|
-
</Box>
|
|
356
|
-
</Box>
|
|
357
|
-
) : (
|
|
358
|
-
<Box>
|
|
359
|
-
<Button variant="solid" color="primary" size="md" onClick={onRetry} dataTestId="checkout-retry-payment">
|
|
360
|
-
Tentar novamente
|
|
361
|
-
</Button>
|
|
362
|
-
</Box>
|
|
363
|
-
)}
|
|
364
|
-
</Box>
|
|
365
|
-
);
|
|
366
|
-
}
|
|
@@ -31,6 +31,23 @@ import type {
|
|
|
31
31
|
interface ApiEnvelope<T> {
|
|
32
32
|
data?: T;
|
|
33
33
|
error?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Stable machine code for the failure (`checkoutErrorResponse` always sends
|
|
36
|
+
* one). Carried through so a surface can PRESENT a refusal for what it is —
|
|
37
|
+
* an unresolved charge is not a decline, and rendering it under "não foi
|
|
38
|
+
* possível pagar" with a live pay button invites the second payment its own
|
|
39
|
+
* text forbids.
|
|
40
|
+
*/
|
|
41
|
+
code?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A non-2xx envelope as a {@link Result} failure, carrying its machine CODE.
|
|
46
|
+
* The message is what the buyer reads; the code is what a surface uses to
|
|
47
|
+
* decide how to PRESENT it, which a message cannot be parsed for.
|
|
48
|
+
*/
|
|
49
|
+
function refused<T>(json: ApiEnvelope<T> | null): Result<T> {
|
|
50
|
+
return err(json?.error ?? "Não foi possível concluir a operação. Tente novamente.", json?.code);
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
/** Call an API route and normalize the response into a {@link Result}. */
|
|
@@ -41,9 +58,7 @@ async function requestResult<T>(input: string, init?: RequestInit): Promise<Resu
|
|
|
41
58
|
headers: { "Content-Type": "application/json", ...init?.headers },
|
|
42
59
|
});
|
|
43
60
|
const json = (await res.json().catch(() => null)) as ApiEnvelope<T> | null;
|
|
44
|
-
if (!res.ok)
|
|
45
|
-
return err(json?.error ?? "Não foi possível concluir a operação. Tente novamente.");
|
|
46
|
-
}
|
|
61
|
+
if (!res.ok) return refused(json);
|
|
47
62
|
if (!json || json.data === undefined) {
|
|
48
63
|
return err(json?.error ?? "Resposta inválida do servidor.");
|
|
49
64
|
}
|
|
@@ -137,6 +152,9 @@ export async function chargeCard(input: ChargeCardInput): Promise<Result<ChargeO
|
|
|
137
152
|
body: JSON.stringify({
|
|
138
153
|
orderId: input.orderId,
|
|
139
154
|
token: input.token,
|
|
155
|
+
// One instrument per provider (FUT-563) — the server hands each provider
|
|
156
|
+
// in the chain its own, which is what lets a card charge fail over.
|
|
157
|
+
...(input.tokensByProvider ? { tokensByProvider: input.tokensByProvider } : {}),
|
|
140
158
|
saveCard: input.saveCard,
|
|
141
159
|
cardMeta: input.cardMeta,
|
|
142
160
|
taxId: input.taxId,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkout failure codes the BUYER SURFACE presents differently (FUT-563).
|
|
3
|
+
*
|
|
4
|
+
* Everything else the server refuses is one thing to a shopper — "it did not
|
|
5
|
+
* work, try again" — and one danger Alert says it. These are the exceptions:
|
|
6
|
+
* codes whose correct rendering is not an error at all, and which a message
|
|
7
|
+
* string cannot be parsed for without turning copy edits into behaviour
|
|
8
|
+
* changes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The charge is IN DOUBT, not failed: some provider may be holding the buyer's
|
|
13
|
+
* money and no probe could say. It is the one outcome where inviting a retry
|
|
14
|
+
* is actively harmful, so the surface must drop every affordance that offers
|
|
15
|
+
* one and must not word it as a failure.
|
|
16
|
+
*/
|
|
17
|
+
export const UNRESOLVED_CODE = "PAYMENT_UNRESOLVED";
|