@12-apps/payments-frontend 3.4.0 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/activation/copy.ts +36 -0
- package/src/activation/mint-charge.ts +77 -0
- package/src/activation/redirect-state.ts +184 -0
- package/src/activation/returned-settlement.ts +117 -0
- package/src/activation/use-redirect-activation.ts +341 -0
- package/src/components/checkout/checkout-steps.tsx +39 -19
- package/src/components/checkout/method-capability.ts +27 -0
- package/src/components/checkout/method-choice.ts +64 -0
- package/src/components/checkout/providers/capability-default.tsx +1 -35
- package/src/components/checkout/providers/hands-over.ts +43 -0
- package/src/components/checkout/providers/hosted-link.tsx +113 -12
- package/src/components/checkout/providers/registry.ts +30 -0
- package/src/components/checkout/providers/types.ts +19 -1
- package/src/components/checkout/types.ts +11 -0
- package/src/index.ts +33 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type React from 'react';
|
|
5
|
+
|
|
6
|
+
import type { RedirectActivationCopy } from './copy';
|
|
7
|
+
import { mintCharge } from './mint-charge';
|
|
8
|
+
import {
|
|
9
|
+
postActivation,
|
|
10
|
+
settleActivationPoll,
|
|
11
|
+
type ActivationClock,
|
|
12
|
+
type ActivationPendingBody,
|
|
13
|
+
type ActivationPollBody,
|
|
14
|
+
type RedirectActivationState,
|
|
15
|
+
} from './redirect-state';
|
|
16
|
+
import { clearReturnedSettlement, takeReturnedSettlement } from './returned-settlement';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The activation step for a provider whose payer pays on ITS page (FUT-463,
|
|
20
|
+
* moved here by FUT-763).
|
|
21
|
+
*
|
|
22
|
+
* Same proof as a card form, different protocol: a REAL link is minted through
|
|
23
|
+
* this store's own connection, the owner pays it on the provider's site, and
|
|
24
|
+
* the provider is then ASKED whether it arrived. Only that answer activates the
|
|
25
|
+
* store. The alternative — telling an owner to "make a real low-value order and
|
|
26
|
+
* confirm the money arrives" — is the dead-end instruction the step exists to
|
|
27
|
+
* abolish.
|
|
28
|
+
*
|
|
29
|
+
* ## What is the package's here, and what is not
|
|
30
|
+
*
|
|
31
|
+
* `renderVerification` on `PaymentProviderSettings` says the package decides
|
|
32
|
+
* only WHERE the step appears, never how it works. That still holds for the
|
|
33
|
+
* SCREEN: the panels, the states an owner reads, the sentences — all of that is
|
|
34
|
+
* the host's, and none of it is here.
|
|
35
|
+
*
|
|
36
|
+
* What is here is the PROTOCOL, which is not a matter of taste and is not
|
|
37
|
+
* something a second host should get to rediscover: pick the outstanding charge
|
|
38
|
+
* back up on mount, ask with the return trip's ids on every tick, keep a
|
|
39
|
+
* refused ATTEMPT distinct from an expired charge, keep an unreachable provider
|
|
40
|
+
* distinct from a refusing one, stop asking eventually. Every one of those was
|
|
41
|
+
* learned from a payment that went wrong, and three of them cost a real charge.
|
|
42
|
+
*
|
|
43
|
+
* ## Why the attempt is the SERVER's
|
|
44
|
+
*
|
|
45
|
+
* It used to live in React state, and that was wrong in a way that costs money.
|
|
46
|
+
* The flow deliberately sends the owner to another site to pay, so "left this
|
|
47
|
+
* page and came back" is the NORMAL path, not an edge case — and it erased the
|
|
48
|
+
* attempt every time. The screen then offered to generate a charge, because as
|
|
49
|
+
* far as it knew none existed: a second real charge on the owner's own card,
|
|
50
|
+
* while the first, already paid, was never asked about again.
|
|
51
|
+
*
|
|
52
|
+
* So the outstanding charge is loaded from the server on mount, and the only
|
|
53
|
+
* things that clear it are settlement and an explicit "give up".
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/** How often to ask the provider whether the payment landed. */
|
|
57
|
+
const DEFAULT_POLL_MS = 4000;
|
|
58
|
+
|
|
59
|
+
/** Give up asking after this long, rather than polling a tab forever. */
|
|
60
|
+
const DEFAULT_POLL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
61
|
+
|
|
62
|
+
export interface RedirectActivationOptions {
|
|
63
|
+
/**
|
|
64
|
+
* The host's verify-charge endpoint for this provider.
|
|
65
|
+
*
|
|
66
|
+
* A whole URL, not the parts of one: the route shape belongs to the host and
|
|
67
|
+
* the package has no business assembling it. `GET` answers the outstanding
|
|
68
|
+
* charge; `POST` takes `{ action: 'start' | 'poll' | 'discard' }`.
|
|
69
|
+
*/
|
|
70
|
+
verifyChargeUrl: string;
|
|
71
|
+
/** A passing charge — the caller refreshes so the provider shows as active. */
|
|
72
|
+
onVerified: () => void;
|
|
73
|
+
/**
|
|
74
|
+
* The provider would not mint a link. Called instead of, not as well as,
|
|
75
|
+
* settling anything: no charge exists, so there is nothing to confirm — the
|
|
76
|
+
* caller uses it to reopen the setup step this failure implicates.
|
|
77
|
+
*/
|
|
78
|
+
onCreateFailed?: () => void;
|
|
79
|
+
copy: RedirectActivationCopy;
|
|
80
|
+
/** Where the return trip's ids are parked. Defaults per the package. */
|
|
81
|
+
storageKey?: string;
|
|
82
|
+
pollMs?: number;
|
|
83
|
+
pollTimeoutMs?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface RedirectActivation {
|
|
87
|
+
state: RedirectActivationState;
|
|
88
|
+
/**
|
|
89
|
+
* When the provider last answered, as epoch ms (0 before the first ask).
|
|
90
|
+
*
|
|
91
|
+
* Shown as "last checked Ns ago". A spinner alone says "something is
|
|
92
|
+
* happening"; this says the screen is still asking, which is the reassurance
|
|
93
|
+
* someone who has just paid on another site actually wants — and it is what
|
|
94
|
+
* stops them pressing a pay button again to be sure.
|
|
95
|
+
*/
|
|
96
|
+
lastCheckedAt: number;
|
|
97
|
+
/** Mint the link and begin asking. */
|
|
98
|
+
start: () => Promise<void>;
|
|
99
|
+
/** Ask right now, instead of waiting for the next tick. */
|
|
100
|
+
checkNow: () => Promise<void>;
|
|
101
|
+
/** Abandon the outstanding charge and offer a fresh one. */
|
|
102
|
+
reset: () => void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* What the effects call, read through a ref rather than through their deps.
|
|
107
|
+
*
|
|
108
|
+
* NOT a style preference — it is what makes the hook safe to hand to a host.
|
|
109
|
+
* Every callback here changes identity when the caller re-renders without
|
|
110
|
+
* memoizing, and both effects below would then re-run on it: the resume effect
|
|
111
|
+
* would re-mount the whole sequence (and, since it sets state, re-render, and
|
|
112
|
+
* loop), and the poll timer would be cleared and restarted before it ever
|
|
113
|
+
* ticked — a screen that asks the provider nothing, for ever, while looking
|
|
114
|
+
* exactly like one that is asking.
|
|
115
|
+
*
|
|
116
|
+
* The host that wrote this flow happened to memoize, so neither ever fired
|
|
117
|
+
* there. A package cannot rely on that: `useRedirectActivation({ onVerified:
|
|
118
|
+
* () => reload() })` is the obvious way to call it.
|
|
119
|
+
*/
|
|
120
|
+
interface ActivationCallbacks {
|
|
121
|
+
applyPoll: (body: ActivationPollBody | null) => boolean;
|
|
122
|
+
returned: () => Record<string, string>;
|
|
123
|
+
timedOut: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Ask the provider every few seconds, and stop asking eventually.
|
|
128
|
+
*
|
|
129
|
+
* The give-up is a `failed` state carrying the host's own sentence, because the
|
|
130
|
+
* wording is load-bearing: the charge is STILL valid, and an owner who has
|
|
131
|
+
* genuinely paid must not be told they did not pay in time.
|
|
132
|
+
*/
|
|
133
|
+
function usePollTimer(
|
|
134
|
+
active: boolean,
|
|
135
|
+
url: string,
|
|
136
|
+
live: ActivationClock,
|
|
137
|
+
latest: React.MutableRefObject<ActivationCallbacks>,
|
|
138
|
+
setState: React.Dispatch<React.SetStateAction<RedirectActivationState>>,
|
|
139
|
+
pollMs: number,
|
|
140
|
+
pollTimeoutMs: number,
|
|
141
|
+
): void {
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!active) return;
|
|
144
|
+
const timer = setInterval(() => {
|
|
145
|
+
if (!live.current.polling) return;
|
|
146
|
+
if (Date.now() - live.current.startedAt > pollTimeoutMs) {
|
|
147
|
+
live.current.polling = false;
|
|
148
|
+
setState({ kind: 'failed', reason: latest.current.timedOut });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// WITH the ids, every tick. A hint-less poll is measured to answer "not
|
|
152
|
+
// paid" for payments that happened — one hinted ask on resume made a
|
|
153
|
+
// single dropped request fatal.
|
|
154
|
+
void postActivation(url, 'poll', latest.current.returned()).then((body) =>
|
|
155
|
+
latest.current.applyPoll(body),
|
|
156
|
+
);
|
|
157
|
+
}, pollMs);
|
|
158
|
+
return () => clearInterval(timer);
|
|
159
|
+
}, [active, url, live, latest, setState, pollMs, pollTimeoutMs]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Pick the outstanding charge back up on mount.
|
|
164
|
+
*
|
|
165
|
+
* This is what makes the return trip work at all. The owner is sent to the
|
|
166
|
+
* provider's site to pay and comes back here holding the ids that let the
|
|
167
|
+
* payment be confirmed — so the very first thing this does on load is ask
|
|
168
|
+
* whether a charge is outstanding and, if so, check it immediately with
|
|
169
|
+
* whatever the redirect brought back.
|
|
170
|
+
*/
|
|
171
|
+
function useResumeOutstanding(
|
|
172
|
+
url: string,
|
|
173
|
+
live: ActivationClock,
|
|
174
|
+
latest: React.MutableRefObject<ActivationCallbacks>,
|
|
175
|
+
setState: React.Dispatch<React.SetStateAction<RedirectActivationState>>,
|
|
176
|
+
): void {
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
const alive = { current: true };
|
|
179
|
+
const carried = latest.current.returned();
|
|
180
|
+
|
|
181
|
+
// An owner who came back holding a settlement has DEMONSTRABLY paid, so the
|
|
182
|
+
// screen must be confirming from the first frame — never showing the pay
|
|
183
|
+
// button, whatever the pending row says and whatever any single request
|
|
184
|
+
// does. `checkoutUrl: null` renders the confirming panel without an
|
|
185
|
+
// open-link button. This also arms the timer, so one dropped request costs
|
|
186
|
+
// four seconds rather than the payment.
|
|
187
|
+
if (Object.keys(carried).length > 0) {
|
|
188
|
+
live.current.polling = true;
|
|
189
|
+
live.current.startedAt = Date.now();
|
|
190
|
+
setState({ kind: 'awaiting', checkoutUrl: null });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
void resumeSequence({ url, live, latest, setState }, carried, alive);
|
|
194
|
+
|
|
195
|
+
return () => {
|
|
196
|
+
alive.current = false;
|
|
197
|
+
};
|
|
198
|
+
}, [url, live, latest, setState]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Everything the resume sequence reaches for, gathered once. */
|
|
202
|
+
interface ResumeIo {
|
|
203
|
+
url: string;
|
|
204
|
+
live: ActivationClock;
|
|
205
|
+
latest: React.MutableRefObject<ActivationCallbacks>;
|
|
206
|
+
setState: React.Dispatch<React.SetStateAction<RedirectActivationState>>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The resume sequence, in the order that keeps a paid return confirmable. */
|
|
210
|
+
async function resumeSequence(
|
|
211
|
+
io: ResumeIo,
|
|
212
|
+
carried: Record<string, string>,
|
|
213
|
+
alive: { current: boolean },
|
|
214
|
+
): Promise<void> {
|
|
215
|
+
const cameBack = Object.keys(carried).length > 0;
|
|
216
|
+
// Fired FIRST, and not gated on the pending GET below: this ask depends on
|
|
217
|
+
// nothing but the returned ids, and chaining it behind another request's
|
|
218
|
+
// success is how a mid-refresh session cookie once turned a confirmed payment
|
|
219
|
+
// into a pay button.
|
|
220
|
+
if (cameBack) {
|
|
221
|
+
const polled = await postActivation(io.url, 'poll', carried);
|
|
222
|
+
if (alive.current) io.latest.current.applyPoll(polled);
|
|
223
|
+
}
|
|
224
|
+
await restoreOutstanding(io, carried, alive, cameBack);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The GET's answer: the outstanding charge, and whether the server settled it
|
|
229
|
+
* during this very read (its own heal path).
|
|
230
|
+
*/
|
|
231
|
+
interface OutstandingBody {
|
|
232
|
+
pending?: ActivationPendingBody;
|
|
233
|
+
proven?: boolean;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function readOutstanding(url: string): Promise<OutstandingBody | null> {
|
|
237
|
+
const response = await fetch(url).catch(() => null);
|
|
238
|
+
if (!response?.ok) return null;
|
|
239
|
+
return (await response.json().catch(() => null)) as OutstandingBody | null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Restore the server's pending row into the screen, and ask once if idle. */
|
|
243
|
+
async function restoreOutstanding(
|
|
244
|
+
io: ResumeIo,
|
|
245
|
+
carried: Record<string, string>,
|
|
246
|
+
alive: { current: boolean },
|
|
247
|
+
cameBack: boolean,
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
const body = await readOutstanding(io.url);
|
|
250
|
+
if (!body || !alive.current) return;
|
|
251
|
+
// Settled server-side while we were reading — the charge is done, and the one
|
|
252
|
+
// thing this must not do now is resume a pay flow for it.
|
|
253
|
+
if (body.proven) {
|
|
254
|
+
io.latest.current.applyPoll({ ok: true });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const pending = body.pending;
|
|
258
|
+
if (!pending) return;
|
|
259
|
+
|
|
260
|
+
io.live.current.polling = true;
|
|
261
|
+
io.live.current.startedAt = Date.parse(pending.startedAt) || Date.now();
|
|
262
|
+
io.setState((current) =>
|
|
263
|
+
current.kind === 'passed' || current.kind === 'failed'
|
|
264
|
+
? current
|
|
265
|
+
: { kind: 'awaiting', checkoutUrl: pending.checkoutUrl },
|
|
266
|
+
);
|
|
267
|
+
if (!cameBack) {
|
|
268
|
+
const polled = await postActivation(io.url, 'poll', carried);
|
|
269
|
+
if (alive.current) io.latest.current.applyPoll(polled);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function useRedirectActivation(options: RedirectActivationOptions): RedirectActivation {
|
|
274
|
+
const {
|
|
275
|
+
verifyChargeUrl: url,
|
|
276
|
+
onVerified,
|
|
277
|
+
onCreateFailed,
|
|
278
|
+
copy,
|
|
279
|
+
storageKey,
|
|
280
|
+
pollMs = DEFAULT_POLL_MS,
|
|
281
|
+
pollTimeoutMs = DEFAULT_POLL_TIMEOUT_MS,
|
|
282
|
+
} = options;
|
|
283
|
+
|
|
284
|
+
const [state, setState] = useState<RedirectActivationState>({ kind: 'idle' });
|
|
285
|
+
const [lastCheckedAt, setLastCheckedAt] = useState(0);
|
|
286
|
+
// Read inside the interval callback so the timer never closes over a stale
|
|
287
|
+
// state — mutating a ref's property rather than reassigning a captured
|
|
288
|
+
// binding, which the flakiness lint rightly rejects.
|
|
289
|
+
const live = useRef({ polling: false, startedAt: 0 });
|
|
290
|
+
|
|
291
|
+
const returned = useCallback(() => takeReturnedSettlement(storageKey), [storageKey]);
|
|
292
|
+
|
|
293
|
+
const applyPoll = useCallback(
|
|
294
|
+
(body: ActivationPollBody | null): boolean => {
|
|
295
|
+
// Stamped even on a dropped request: the counter reports when we last
|
|
296
|
+
// ASKED, and freezing it on a network blip would read as a stalled screen.
|
|
297
|
+
setLastCheckedAt(Date.now());
|
|
298
|
+
return settleActivationPoll(body, {
|
|
299
|
+
live,
|
|
300
|
+
setState,
|
|
301
|
+
onVerified,
|
|
302
|
+
clearSettlement: () => clearReturnedSettlement(storageKey),
|
|
303
|
+
copy,
|
|
304
|
+
});
|
|
305
|
+
},
|
|
306
|
+
[onVerified, storageKey, copy],
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const latest = useRef<ActivationCallbacks>({
|
|
310
|
+
applyPoll,
|
|
311
|
+
returned,
|
|
312
|
+
timedOut: copy.confirmTimedOut,
|
|
313
|
+
});
|
|
314
|
+
latest.current = { applyPoll, returned, timedOut: copy.confirmTimedOut };
|
|
315
|
+
|
|
316
|
+
useResumeOutstanding(url, live, latest, setState);
|
|
317
|
+
usePollTimer(state.kind === 'awaiting', url, live, latest, setState, pollMs, pollTimeoutMs);
|
|
318
|
+
|
|
319
|
+
const start = useCallback(
|
|
320
|
+
() => mintCharge({ url, live, setState, copy, onCreateFailed }),
|
|
321
|
+
[url, copy, onCreateFailed],
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const checkNow = useCallback(async () => {
|
|
325
|
+
applyPoll(await postActivation(url, 'poll', returned()));
|
|
326
|
+
}, [applyPoll, url, returned]);
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Give up on the outstanding charge — and tell the SERVER so.
|
|
330
|
+
*
|
|
331
|
+
* Clearing only the local state would leave the charge on record, so the very
|
|
332
|
+
* next load would resume the attempt the owner just abandoned.
|
|
333
|
+
*/
|
|
334
|
+
const reset = useCallback(() => {
|
|
335
|
+
live.current.polling = false;
|
|
336
|
+
setState({ kind: 'idle' });
|
|
337
|
+
void postActivation(url, 'discard');
|
|
338
|
+
}, [url]);
|
|
339
|
+
|
|
340
|
+
return { state, lastCheckedAt, start, checkNow, reset };
|
|
341
|
+
}
|
|
@@ -3,12 +3,7 @@ import { useEffect, useRef, type JSX, type ReactNode } from "react";
|
|
|
3
3
|
|
|
4
4
|
import { BuyerInfoForm } from "./buyer-info-form";
|
|
5
5
|
import { LockOutlinedIcon } from "./icons";
|
|
6
|
-
import {
|
|
7
|
-
cardPathAvailable,
|
|
8
|
-
offeredMethods,
|
|
9
|
-
selectableMethods,
|
|
10
|
-
usePreselectSoleMethod,
|
|
11
|
-
} from "./method-capability";
|
|
6
|
+
import { useMethodChoice } from "./method-choice";
|
|
12
7
|
import { MethodPicker } from "./method-picker";
|
|
13
8
|
import { PaymentErrorPanel } from "./payment-error-panel";
|
|
14
9
|
import { PayerSummary } from "./payer-summary";
|
|
@@ -42,8 +37,8 @@ function useAutoRaiseOrder(
|
|
|
42
37
|
requestedFor.current = null;
|
|
43
38
|
return;
|
|
44
39
|
}
|
|
45
|
-
// No method chosen yet ⇒ show only the picker
|
|
46
|
-
//
|
|
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
|
|
327
|
-
const
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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={
|
|
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
|
-
{
|
|
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
|
|
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
|
+
}
|