@12-apps/payments-frontend 1.0.0 → 1.2.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 +7 -4
- package/src/__tests__/provider-priority-list.test.tsx +2 -2
- package/src/__tests__/slugged-provider.test.tsx +108 -0
- package/src/card/cpf.ts +42 -0
- package/src/card/fields.tsx +254 -0
- package/src/card/format.ts +103 -0
- package/src/card/index.ts +42 -0
- package/src/card/stripe-token.ts +81 -0
- package/src/card/tokenize.test.ts +194 -0
- package/src/card/tokenize.ts +327 -0
- package/src/card/types.ts +54 -0
- package/src/components/PaymentProviderSettings.tsx +30 -4
- package/src/components/checkout/__tests__/card-3ds-handover.test.tsx +147 -0
- package/src/components/checkout/__tests__/clear-cart-on-paid.test.tsx +64 -0
- package/src/components/checkout/__tests__/hosted-return.test.ts +109 -0
- package/src/components/checkout/__tests__/method-capability.test.tsx +120 -0
- package/src/components/checkout/__tests__/payments-unavailable.test.tsx +53 -0
- package/src/components/checkout/__tests__/save-on-continue.test.tsx +165 -0
- package/src/components/checkout/__tests__/second-host.test.tsx +86 -0
- package/src/components/checkout/buyer-info-form.tsx +138 -0
- package/src/components/checkout/card-view.tsx +128 -0
- package/src/components/checkout/checkout-flow.tsx +201 -0
- package/src/components/checkout/checkout-steps.tsx +366 -0
- package/src/components/checkout/client.ts +157 -0
- package/src/components/checkout/hosted-return.ts +92 -0
- package/src/components/checkout/icons.tsx +61 -0
- package/src/components/checkout/method-capability.ts +69 -0
- package/src/components/checkout/method-picker.tsx +153 -0
- package/src/components/checkout/mui-defaults.tsx +218 -0
- package/src/components/checkout/payer-summary.tsx +81 -0
- package/src/components/checkout/payment-status.tsx +256 -0
- package/src/components/checkout/payments-unavailable.tsx +79 -0
- package/src/components/checkout/pix-view.tsx +179 -0
- package/src/components/checkout/types.ts +223 -0
- package/src/components/checkout/ui.tsx +171 -0
- package/src/components/checkout/use-card-checkout.ts +346 -0
- package/src/components/checkout/use-checkout-controller.ts +252 -0
- package/src/components/checkout/use-payment-polling.ts +93 -0
- package/src/components/settings-state.ts +45 -2
- package/src/index.ts +74 -1
- package/src/result.ts +11 -0
- package/src/components/CheckoutFlow.tsx +0 -169
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
NEW_CARD,
|
|
5
|
+
detectBrand,
|
|
6
|
+
onlyDigits,
|
|
7
|
+
tokenizeForCheckout,
|
|
8
|
+
tokenizerFor,
|
|
9
|
+
validateCardNumber,
|
|
10
|
+
validateCvv,
|
|
11
|
+
validateExpiry,
|
|
12
|
+
validateHolder,
|
|
13
|
+
type CardBrand,
|
|
14
|
+
type CardDetails,
|
|
15
|
+
type CardFieldErrors,
|
|
16
|
+
type CardTokenizationConfig,
|
|
17
|
+
type CardToken,
|
|
18
|
+
type SavedCard,
|
|
19
|
+
} from "../../card";
|
|
20
|
+
import { ok, type Result } from "../../result";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
chargeCard,
|
|
24
|
+
listSavedCards,
|
|
25
|
+
refreshCardPublicKey,
|
|
26
|
+
} from "./client";
|
|
27
|
+
import { rememberHostedOrder } from "./hosted-return";
|
|
28
|
+
import type {
|
|
29
|
+
BuyerInfo,
|
|
30
|
+
CheckoutOrder,
|
|
31
|
+
OrderStatus,
|
|
32
|
+
SavedCardMeta,
|
|
33
|
+
} from "./types";
|
|
34
|
+
import { usePaymentPolling } from "./use-payment-polling";
|
|
35
|
+
|
|
36
|
+
const EMPTY_CARD: CardDetails = { number: "", holder: "", expiry: "", cvv: "" };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Tokenize a new card in the ACTIVE provider's protocol (FUT-697), self-healing
|
|
40
|
+
* a rotated public key (FUT-174): the card has already passed local validation,
|
|
41
|
+
* so a real-key encryption failure most likely means the store's key rotated.
|
|
42
|
+
* Refresh the store's key once and retry before surfacing the error;
|
|
43
|
+
* `onKeyRefreshed` caches the new key for the session. The refresh is scoped to
|
|
44
|
+
* the buyer's OWN `orderId` (the route derives the store from it server-side),
|
|
45
|
+
* never a client-supplied store id — and only PagBank can mint a key on demand,
|
|
46
|
+
* so the self-heal is gated on its scheme.
|
|
47
|
+
*/
|
|
48
|
+
async function tokenizeNewCard(
|
|
49
|
+
card: CardDetails,
|
|
50
|
+
config: CardTokenizationConfig,
|
|
51
|
+
orderId: string,
|
|
52
|
+
onKeyRefreshed: (key: string) => void,
|
|
53
|
+
): Promise<Result<CardToken>> {
|
|
54
|
+
const first = await tokenizeForCheckout(card, config);
|
|
55
|
+
if (first.ok || !config.publicKey) return first;
|
|
56
|
+
if (config.provider === null || tokenizerFor(config.provider) !== "pagbank-sdk") return first;
|
|
57
|
+
|
|
58
|
+
const refreshed = await refreshCardPublicKey({ orderId });
|
|
59
|
+
if (refreshed.ok && refreshed.data.publicKey && refreshed.data.publicKey !== config.publicKey) {
|
|
60
|
+
onKeyRefreshed(refreshed.data.publicKey);
|
|
61
|
+
return tokenizeForCheckout(card, { ...config, publicKey: refreshed.data.publicKey });
|
|
62
|
+
}
|
|
63
|
+
return first;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Non-sensitive display metadata for saving a card (the PAN never leaves the form). */
|
|
67
|
+
function toCardMeta(card: CardDetails, token: CardToken): SavedCardMeta {
|
|
68
|
+
const [mm = "", yy = ""] = card.expiry.split("/");
|
|
69
|
+
return {
|
|
70
|
+
brand: token.brand,
|
|
71
|
+
last4: token.last4,
|
|
72
|
+
expMonth: Number(mm),
|
|
73
|
+
expYear: 2000 + Number(yy),
|
|
74
|
+
holder: card.holder.trim(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The charge token for a new card (tokenize + self-heal), plus optional save-meta. */
|
|
79
|
+
async function resolveNewCardToken(
|
|
80
|
+
card: CardDetails,
|
|
81
|
+
config: CardTokenizationConfig,
|
|
82
|
+
orderId: string,
|
|
83
|
+
onKeyRefreshed: (key: string) => void,
|
|
84
|
+
saveCard: boolean,
|
|
85
|
+
): Promise<Result<{ token: string; cardMeta?: SavedCardMeta }>> {
|
|
86
|
+
const tokenized = await tokenizeNewCard(card, config, orderId, onKeyRefreshed);
|
|
87
|
+
if (!tokenized.ok) return tokenized;
|
|
88
|
+
return ok({
|
|
89
|
+
token: tokenized.data.token,
|
|
90
|
+
...(saveCard ? { cardMeta: toCardMeta(card, tokenized.data) } : {}),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Saved-card list + current selection, loaded once on mount — scoped to the
|
|
96
|
+
* current store (FUT-697), so only cards the store's active provider can
|
|
97
|
+
* actually charge are offered. The slug arrives as an argument (the host's
|
|
98
|
+
* routing owns it); absent, the list is unscoped, exactly as before FUT-697.
|
|
99
|
+
*/
|
|
100
|
+
function useSavedCards(tenantSlug: string | undefined): {
|
|
101
|
+
savedCards: SavedCard[];
|
|
102
|
+
selection: string;
|
|
103
|
+
setSelection: (id: string) => void;
|
|
104
|
+
} {
|
|
105
|
+
const [savedCards, setSavedCards] = useState<SavedCard[]>([]);
|
|
106
|
+
const [selection, setSelection] = useState<string>(NEW_CARD);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
let active = true;
|
|
110
|
+
void listSavedCards(tenantSlug).then((cards) => {
|
|
111
|
+
if (!active) return;
|
|
112
|
+
setSavedCards(cards);
|
|
113
|
+
const first = cards[0];
|
|
114
|
+
if (first) setSelection(first.id);
|
|
115
|
+
});
|
|
116
|
+
return () => {
|
|
117
|
+
active = false;
|
|
118
|
+
};
|
|
119
|
+
}, [tenantSlug]);
|
|
120
|
+
|
|
121
|
+
return { savedCards, selection, setSelection };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The store's card public key for this order. The checkout config resolved the
|
|
126
|
+
* initial key server-side (`GET /api/checkout/config`, FUT-697); the SPA
|
|
127
|
+
* fetches through the order-scoped refresh route only when the ACTIVE provider
|
|
128
|
+
* is PagBank — the sole provider that can mint a key on demand — and the
|
|
129
|
+
* config arrived without one. A `null` key never means "mock": stub permission
|
|
130
|
+
* travels separately as `mockTokenization`.
|
|
131
|
+
*/
|
|
132
|
+
function useCardPublicKey(
|
|
133
|
+
orderId: string,
|
|
134
|
+
config: CardTokenizationConfig,
|
|
135
|
+
): {
|
|
136
|
+
publicKey: string | null;
|
|
137
|
+
setPublicKey: (key: string) => void;
|
|
138
|
+
} {
|
|
139
|
+
const [publicKey, setPublicKey] = useState<string | null>(config.publicKey);
|
|
140
|
+
const refreshable =
|
|
141
|
+
config.provider !== null && tokenizerFor(config.provider) === "pagbank-sdk";
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (config.publicKey !== null || !refreshable) return undefined;
|
|
145
|
+
let active = true;
|
|
146
|
+
void refreshCardPublicKey({ orderId }).then((result) => {
|
|
147
|
+
if (active && result.ok && result.data.publicKey) setPublicKey(result.data.publicKey);
|
|
148
|
+
});
|
|
149
|
+
return () => {
|
|
150
|
+
active = false;
|
|
151
|
+
};
|
|
152
|
+
}, [orderId, config.publicKey, refreshable]);
|
|
153
|
+
|
|
154
|
+
return { publicKey, setPublicKey };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Everything the card view renders — all checkout state + the submit handler. */
|
|
158
|
+
interface CardCheckout {
|
|
159
|
+
savedCards: SavedCard[];
|
|
160
|
+
selection: string;
|
|
161
|
+
setSelection: (id: string) => void;
|
|
162
|
+
usingNewCard: boolean;
|
|
163
|
+
card: CardDetails;
|
|
164
|
+
setCard: React.Dispatch<React.SetStateAction<CardDetails>>;
|
|
165
|
+
fieldErrors: CardFieldErrors;
|
|
166
|
+
setFieldErrors: React.Dispatch<React.SetStateAction<CardFieldErrors>>;
|
|
167
|
+
brand: CardBrand;
|
|
168
|
+
saveCard: boolean;
|
|
169
|
+
setSaveCard: (checked: boolean) => void;
|
|
170
|
+
error: string | null;
|
|
171
|
+
submitting: boolean;
|
|
172
|
+
submitted: boolean;
|
|
173
|
+
pollError: string | null;
|
|
174
|
+
/** The healthy-poll cap elapsed while still AWAITING (FUT-191 bounded wait). */
|
|
175
|
+
pollTimedOut: boolean;
|
|
176
|
+
handlePay: () => Promise<void>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The form inputs the submit handler reads (owned by {@link useCardCheckout}). */
|
|
180
|
+
interface CardFormState {
|
|
181
|
+
card: CardDetails;
|
|
182
|
+
usingNewCard: boolean;
|
|
183
|
+
selection: string;
|
|
184
|
+
saveCard: boolean;
|
|
185
|
+
validate: () => CardFieldErrors;
|
|
186
|
+
setFieldErrors: React.Dispatch<React.SetStateAction<CardFieldErrors>>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The submit slice of {@link useCardCheckout}. */
|
|
190
|
+
type CardSubmit = Pick<
|
|
191
|
+
CardCheckout,
|
|
192
|
+
"submitting" | "submitted" | "error" | "pollError" | "pollTimedOut" | "handlePay"
|
|
193
|
+
>;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Healthy-poll cap for the card AWAITING wait: 36 polls ≈ 90 s at the 2500 ms
|
|
197
|
+
* default interval (FUT-191). PIX passes no cap and keeps today's behavior.
|
|
198
|
+
*/
|
|
199
|
+
const CARD_AWAITING_POLL_CAP = 36;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Hand the buyer to the provider's authentication page (FUT-698) — Stripe's
|
|
203
|
+
* redirect-based 3-D Secure. Park the order and navigate, the same trip a
|
|
204
|
+
* redirect provider's link takes (FUT-556): the return lands back on this
|
|
205
|
+
* checkout route, where the hosted-resume machinery polls the parked order.
|
|
206
|
+
*/
|
|
207
|
+
function handOverToChallenge(order: CheckoutOrder, url: string): void {
|
|
208
|
+
rememberHostedOrder(order);
|
|
209
|
+
window.location.assign(url);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The submit state machine (FUT-58): validate → tokenize (self-heal) → charge →
|
|
214
|
+
* poll for the async confirmation, bubbling the terminal status up via
|
|
215
|
+
* {@link onResolved}.
|
|
216
|
+
*/
|
|
217
|
+
function useCardSubmit(
|
|
218
|
+
order: CheckoutOrder,
|
|
219
|
+
buyer: BuyerInfo,
|
|
220
|
+
providerConfig: CardTokenizationConfig,
|
|
221
|
+
onResolved: (status: OrderStatus) => void,
|
|
222
|
+
pollIntervalMs: number,
|
|
223
|
+
form: CardFormState,
|
|
224
|
+
): CardSubmit {
|
|
225
|
+
const [submitting, setSubmitting] = useState(false);
|
|
226
|
+
const [submitted, setSubmitted] = useState(false);
|
|
227
|
+
const [error, setError] = useState<string | null>(null);
|
|
228
|
+
// The active key: resolved by the checkout config, overridden by the
|
|
229
|
+
// rotated-key self-heal for the rest of the session.
|
|
230
|
+
const { publicKey, setPublicKey } = useCardPublicKey(order.orderId, providerConfig);
|
|
231
|
+
|
|
232
|
+
const { status, error: pollError, timedOut: pollTimedOut } = usePaymentPolling(order.orderId, {
|
|
233
|
+
enabled: submitted,
|
|
234
|
+
intervalMs: pollIntervalMs,
|
|
235
|
+
maxHealthyPolls: CARD_AWAITING_POLL_CAP,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (status && status !== "AWAITING_PAYMENT") onResolved(status);
|
|
240
|
+
}, [status, onResolved]);
|
|
241
|
+
|
|
242
|
+
async function resolveToken(): Promise<Result<{ token: string; cardMeta?: SavedCardMeta }>> {
|
|
243
|
+
if (!form.usingNewCard) return ok({ token: form.selection }); // reuse saved card's token id
|
|
244
|
+
return resolveNewCardToken(
|
|
245
|
+
form.card,
|
|
246
|
+
{ ...providerConfig, publicKey },
|
|
247
|
+
order.orderId,
|
|
248
|
+
setPublicKey,
|
|
249
|
+
form.saveCard,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const handlePay = async (): Promise<void> => {
|
|
254
|
+
setError(null);
|
|
255
|
+
if (form.usingNewCard) {
|
|
256
|
+
const errors = form.validate();
|
|
257
|
+
form.setFieldErrors(errors);
|
|
258
|
+
if (Object.values(errors).some(Boolean)) return; // block submit until valid
|
|
259
|
+
}
|
|
260
|
+
setSubmitting(true);
|
|
261
|
+
|
|
262
|
+
const resolved = await resolveToken();
|
|
263
|
+
if (!resolved.ok) {
|
|
264
|
+
setError(resolved.error);
|
|
265
|
+
setSubmitting(false);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const charged = await chargeCard({
|
|
270
|
+
orderId: order.orderId,
|
|
271
|
+
token: resolved.data.token,
|
|
272
|
+
saveCard: form.usingNewCard && form.saveCard,
|
|
273
|
+
cardMeta: resolved.data.cardMeta,
|
|
274
|
+
taxId: buyer.taxId,
|
|
275
|
+
});
|
|
276
|
+
if (!charged.ok) {
|
|
277
|
+
setError(charged.error); // transport/validation problem — stay on the form
|
|
278
|
+
setSubmitting(false);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// 3-D Secure (FUT-698): the buyer must finish on the provider's page.
|
|
282
|
+
// `submitting` stays true on purpose — the tab is navigating away.
|
|
283
|
+
if (charged.data.hostedCheckoutUrl) {
|
|
284
|
+
return handOverToChallenge(order, charged.data.hostedCheckoutUrl);
|
|
285
|
+
}
|
|
286
|
+
// A business outcome (e.g. declined → FAILED) shows the status screen;
|
|
287
|
+
// an accepted charge begins polling for the async confirmation.
|
|
288
|
+
if (charged.data.status !== "AWAITING_PAYMENT") onResolved(charged.data.status);
|
|
289
|
+
else setSubmitted(true);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
return { submitting, submitted, error, pollError, pollTimedOut, handlePay };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* All card-payment state + the async submit handler (FUT-58), extracted so the
|
|
297
|
+
* card view stays presentational.
|
|
298
|
+
*/
|
|
299
|
+
export function useCardCheckout(
|
|
300
|
+
order: CheckoutOrder,
|
|
301
|
+
buyer: BuyerInfo,
|
|
302
|
+
providerConfig: CardTokenizationConfig,
|
|
303
|
+
onResolved: (status: OrderStatus) => void,
|
|
304
|
+
pollIntervalMs: number,
|
|
305
|
+
/** The store whose saved cards may be offered (host routing owns the slug). */
|
|
306
|
+
tenantSlug?: string,
|
|
307
|
+
): CardCheckout {
|
|
308
|
+
const { savedCards, selection, setSelection } = useSavedCards(tenantSlug);
|
|
309
|
+
const [card, setCard] = useState<CardDetails>(EMPTY_CARD);
|
|
310
|
+
const [fieldErrors, setFieldErrors] = useState<CardFieldErrors>({});
|
|
311
|
+
const [saveCard, setSaveCard] = useState(false);
|
|
312
|
+
|
|
313
|
+
const brand = detectBrand(onlyDigits(card.number));
|
|
314
|
+
const usingNewCard = selection === NEW_CARD;
|
|
315
|
+
|
|
316
|
+
const validate = (): CardFieldErrors => ({
|
|
317
|
+
number: validateCardNumber(card.number),
|
|
318
|
+
holder: validateHolder(card.holder),
|
|
319
|
+
expiry: validateExpiry(card.expiry),
|
|
320
|
+
cvv: validateCvv(card.cvv, brand),
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const submit = useCardSubmit(order, buyer, providerConfig, onResolved, pollIntervalMs, {
|
|
324
|
+
card,
|
|
325
|
+
usingNewCard,
|
|
326
|
+
selection,
|
|
327
|
+
saveCard,
|
|
328
|
+
validate,
|
|
329
|
+
setFieldErrors,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
savedCards,
|
|
334
|
+
selection,
|
|
335
|
+
setSelection,
|
|
336
|
+
usingNewCard,
|
|
337
|
+
card,
|
|
338
|
+
setCard,
|
|
339
|
+
fieldErrors,
|
|
340
|
+
setFieldErrors,
|
|
341
|
+
brand,
|
|
342
|
+
saveCard,
|
|
343
|
+
setSaveCard,
|
|
344
|
+
...submit,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
|
2
|
+
|
|
3
|
+
import { validateCpf } from "../../card";
|
|
4
|
+
|
|
5
|
+
import { rememberHostedOrder, takeHostedOrder } from "./hosted-return";
|
|
6
|
+
import type {
|
|
7
|
+
BuyerContact,
|
|
8
|
+
BuyerField,
|
|
9
|
+
BuyerInfo,
|
|
10
|
+
CheckoutOrder,
|
|
11
|
+
CreateOrderRequest,
|
|
12
|
+
CreateOrderResult,
|
|
13
|
+
OrderStatus,
|
|
14
|
+
PaymentMethod,
|
|
15
|
+
} from "./types";
|
|
16
|
+
import { usePaymentPolling } from "./use-payment-polling";
|
|
17
|
+
|
|
18
|
+
type Step = "dados" | "payment" | "status";
|
|
19
|
+
|
|
20
|
+
const STEP_ORDER: Step[] = ["dados", "payment", "status"];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Everything the flow needs FROM its host, as explicit ports (FUT-564). The
|
|
24
|
+
* package owns the payment surface; the host owns the cart, the catalog,
|
|
25
|
+
* order creation and its own routing — these callbacks are the entire seam
|
|
26
|
+
* between the two, which is what lets the package import nothing from any app.
|
|
27
|
+
*/
|
|
28
|
+
export interface CheckoutHostPorts {
|
|
29
|
+
/**
|
|
30
|
+
* Raise the order (and its first charge). The host closes over everything
|
|
31
|
+
* the flow must not know: WHICH cart, WHICH tenant, WHICH comanda scope.
|
|
32
|
+
*/
|
|
33
|
+
createOrder: (input: CreateOrderRequest) => Promise<CreateOrderResult>;
|
|
34
|
+
/**
|
|
35
|
+
* Persist the buyer's contact when they press "Continuar" on Dados — the
|
|
36
|
+
* host's account surface owns the write (and the blank-CPF-never-clears
|
|
37
|
+
* rule). Fire-and-forget by contract: the flow advances regardless of the
|
|
38
|
+
* outcome, and only calls this under the "salvar meus dados" consent.
|
|
39
|
+
*/
|
|
40
|
+
saveBuyerContact?: (contact: BuyerContact) => void;
|
|
41
|
+
/** Leave checkout for the host's menu/catalog. */
|
|
42
|
+
onExitToMenu: () => void;
|
|
43
|
+
/**
|
|
44
|
+
* The order settled PAID. The storefront re-reads its cart here — the
|
|
45
|
+
* server emptied it inside the confirmation transaction (FUT-601) and
|
|
46
|
+
* nothing else tells the SPA. Never fired for FAILED/EXPIRED: that shopper
|
|
47
|
+
* still has a basket to retry with.
|
|
48
|
+
*/
|
|
49
|
+
onPaid?: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The flow's navigation actions, split out of {@link useCheckoutController} for
|
|
54
|
+
* the 80-line per-function gate.
|
|
55
|
+
*
|
|
56
|
+
* `back` is where "the Dados step was skipped" has to be honoured: going back
|
|
57
|
+
* off Pagamento normally lands on Dados, but for a buyer with a CPF on file
|
|
58
|
+
* that step is not part of their flow, so the menu is the only honest
|
|
59
|
+
* destination — until they open it themselves via `editBuyer` ("alterar" on the
|
|
60
|
+
* payer block), after which it IS part of their flow and back returns to it.
|
|
61
|
+
*/
|
|
62
|
+
function useCheckoutNav(
|
|
63
|
+
taxIdOnFile: boolean,
|
|
64
|
+
goToMenu: () => void,
|
|
65
|
+
setStep: Dispatch<SetStateAction<Step>>,
|
|
66
|
+
): { back: () => void; editBuyer: (() => void) | undefined } {
|
|
67
|
+
const [dadosOpened, setDadosOpened] = useState(false);
|
|
68
|
+
const openDados = useCallback(() => {
|
|
69
|
+
setDadosOpened(true);
|
|
70
|
+
setStep("dados");
|
|
71
|
+
}, [setStep]);
|
|
72
|
+
const back = useCallback(() => {
|
|
73
|
+
setStep((current) => {
|
|
74
|
+
if (current === "payment" && (!taxIdOnFile || dadosOpened)) return "dados";
|
|
75
|
+
goToMenu();
|
|
76
|
+
return current;
|
|
77
|
+
});
|
|
78
|
+
}, [dadosOpened, goToMenu, setStep, taxIdOnFile]);
|
|
79
|
+
// Undefined unless Dados was skipped — the payer block keys off its presence,
|
|
80
|
+
// so the decision lives here rather than being re-derived by every caller.
|
|
81
|
+
return { back, editBuyer: taxIdOnFile ? openDados : undefined };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What the "Continuar" gate objects to about the CPF, or undefined to advance.
|
|
86
|
+
*
|
|
87
|
+
* A blank field is only an error when the store has NO CPF for this buyer. With
|
|
88
|
+
* one on file the field starts empty by design (the client is never sent the
|
|
89
|
+
* saved CPF), so demanding one here trapped a returning buyer who opened Dados
|
|
90
|
+
* through "Alterar" and changed their mind: they could not reach Pagamento
|
|
91
|
+
* again, and back only led out to the menu. Leaving it blank means "charge me
|
|
92
|
+
* as before", which is exactly what the server's `resolveBuyerTaxId` does.
|
|
93
|
+
*/
|
|
94
|
+
function cpfGateError(typed: string, taxIdOnFile: boolean): string | undefined {
|
|
95
|
+
if (!typed && taxIdOnFile) return undefined;
|
|
96
|
+
return validateCpf(typed);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Where checkout opens: on the outcome for a buyer returning from a hosted
|
|
101
|
+
* provider, on Pagamento when their CPF is already on file, else on Dados.
|
|
102
|
+
*/
|
|
103
|
+
function initialStep(resuming: boolean, taxIdOnFile: boolean): Step {
|
|
104
|
+
if (resuming) return "status";
|
|
105
|
+
return taxIdOnFile ? "payment" : "dados";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Hand the buyer to a redirect provider's own page, if that is where this
|
|
110
|
+
* charge settles (FUT-556).
|
|
111
|
+
*
|
|
112
|
+
* Called BEFORE the order is stored: storing it first would render the PIX or
|
|
113
|
+
* card step for a provider that returned neither, which is the dead end this
|
|
114
|
+
* fixes.
|
|
115
|
+
*
|
|
116
|
+
* A full navigation rather than the host's router, because the destination is
|
|
117
|
+
* another origin. The return trip comes back to this same checkout route
|
|
118
|
+
* carrying `transaction_nsu` + `slug`, which the status poll already reads.
|
|
119
|
+
*
|
|
120
|
+
* @returns true when the buyer is on their way and the caller must stop.
|
|
121
|
+
*/
|
|
122
|
+
function handOverToProvider(order: CheckoutOrder): boolean {
|
|
123
|
+
if (!order.hostedCheckoutUrl) return false;
|
|
124
|
+
rememberHostedOrder(order);
|
|
125
|
+
window.location.assign(order.hostedCheckoutUrl);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The leg of checkout that resumes after a hosted provider sent the buyer back
|
|
131
|
+
* (FUT-556).
|
|
132
|
+
*
|
|
133
|
+
* The SPA was torn down by the redirect, so the order is rehydrated from the
|
|
134
|
+
* parked copy — once, on first render — and polled here rather than in a PIX or
|
|
135
|
+
* card view, because a redirect provider produced neither. The webhook is still
|
|
136
|
+
* what settles the order; this only tells the buyer that it did.
|
|
137
|
+
*/
|
|
138
|
+
function useHostedResume(): { order: CheckoutOrder | null; status: OrderStatus | null } {
|
|
139
|
+
const [order] = useState(takeHostedOrder);
|
|
140
|
+
const { status } = usePaymentPolling(order?.orderId ?? null, { enabled: Boolean(order) });
|
|
141
|
+
return { order, status };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Fire the host's `onPaid` port once the order settles PAID.
|
|
146
|
+
*
|
|
147
|
+
* FUT-601 made the SERVER empty the cart inside the confirmation transaction —
|
|
148
|
+
* but nothing told the SPA, whose cart provider survives every checkout route
|
|
149
|
+
* change and kept counting the items the buyer had just bought. The server was
|
|
150
|
+
* right and the screen was stale; this is where the host is told to catch up.
|
|
151
|
+
*
|
|
152
|
+
* PAID only. A FAILED or EXPIRED order fires nothing — that shopper still has
|
|
153
|
+
* a basket to retry with, and the host must not be told otherwise.
|
|
154
|
+
*/
|
|
155
|
+
function usePaidPort(settled: OrderStatus | null, onPaid: (() => void) | undefined): void {
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (settled !== "PAID") return;
|
|
158
|
+
onPaid?.();
|
|
159
|
+
}, [settled, onPaid]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* All checkout state + handlers, so the checkout flow stays presentational.
|
|
164
|
+
* - `setMethod` drops any order raised for the previous method (no stale QR/form).
|
|
165
|
+
* - `back` is step-aware: Pagamento → Dados, else to the menu. With a CPF on
|
|
166
|
+
* file the flow never has a Dados step, so Pagamento goes back to the menu.
|
|
167
|
+
* - `goToPayment` gates on the CPF (required for every charge) and PERSISTS the
|
|
168
|
+
* buyer's contact — through the host port — before advancing; nothing
|
|
169
|
+
* downstream of this step is allowed to be a precondition for their details
|
|
170
|
+
* being saved.
|
|
171
|
+
* - `startPayment` raises the order (PIX → QR, CARD → chargeable); `payWithEmail`
|
|
172
|
+
* re-raises with a corrected e-mail for the merchant-email rejection.
|
|
173
|
+
*/
|
|
174
|
+
export function useCheckoutController(
|
|
175
|
+
ports: CheckoutHostPorts,
|
|
176
|
+
defaultBuyer?: BuyerInfo,
|
|
177
|
+
/**
|
|
178
|
+
* The buyer already has a CPF saved (FUT-465) ⇒ the Dados step has nothing
|
|
179
|
+
* left to ask, so checkout opens on Pagamento and `back` goes to the menu
|
|
180
|
+
* rather than to a form the buyer never saw. The CPF itself is never in the
|
|
181
|
+
* client's hands — the server reads it from the encrypted profile when the
|
|
182
|
+
* charge is raised.
|
|
183
|
+
*/
|
|
184
|
+
taxIdOnFile = false,
|
|
185
|
+
) {
|
|
186
|
+
const { createOrder, saveBuyerContact, onExitToMenu, onPaid } = ports;
|
|
187
|
+
const resume = useHostedResume();
|
|
188
|
+
const [step, setStep] = useState<Step>(initialStep(Boolean(resume.order), taxIdOnFile));
|
|
189
|
+
// No method pre-selected: the Pagamento step shows just the picker until the
|
|
190
|
+
// buyer chooses PIX or card, then that method's order is raised and its UI
|
|
191
|
+
// revealed. Avoids raising a throwaway PIX charge for a buyer who wants card.
|
|
192
|
+
const [method, setMethodState] = useState<PaymentMethod | null>(null);
|
|
193
|
+
const [buyer, setBuyerState] = useState<BuyerInfo>(defaultBuyer ?? {});
|
|
194
|
+
const [saveProfile, setSaveProfile] = useState(true);
|
|
195
|
+
const [order, setOrder] = useState<CheckoutOrder | null>(resume.order);
|
|
196
|
+
const [finalStatus, setFinalStatus] = useState<OrderStatus | null>(null);
|
|
197
|
+
const [creating, setCreating] = useState(false);
|
|
198
|
+
const [createError, setCreateError] = useState<string | null>(null);
|
|
199
|
+
const [errorField, setErrorField] = useState<BuyerField | null>(null);
|
|
200
|
+
|
|
201
|
+
const clearError = useCallback(() => { setCreateError(null); setErrorField(null); }, []);
|
|
202
|
+
const setBuyer = useCallback((next: BuyerInfo) => { setBuyerState(next); clearError(); }, [clearError]);
|
|
203
|
+
const { back, editBuyer } = useCheckoutNav(taxIdOnFile, onExitToMenu, setStep);
|
|
204
|
+
const setMethod = useCallback((next: PaymentMethod) => {
|
|
205
|
+
setMethodState((prev) => { if (prev !== next) { setOrder(null); clearError(); } return next; });
|
|
206
|
+
}, [clearError]);
|
|
207
|
+
const goToPayment = useCallback(() => {
|
|
208
|
+
clearError();
|
|
209
|
+
const cpfError = cpfGateError(buyer.taxId?.trim() ?? "", taxIdOnFile);
|
|
210
|
+
if (cpfError) { setCreateError(cpfError); setErrorField("cpf"); return; }
|
|
211
|
+
// Persist the buyer's details HERE, on "Continuar" — not when a payment is
|
|
212
|
+
// raised. Everything after this step can fail (no provider configured, a
|
|
213
|
+
// declined card, an abandoned PIX, a closed tab) and the details must
|
|
214
|
+
// survive all of it. Fire-and-forget on purpose: making the buyer wait on
|
|
215
|
+
// the write — or blocking them when it fails — would trade the bug for a
|
|
216
|
+
// worse one. Gated on the "salvar meus dados" consent (LGPD), which is
|
|
217
|
+
// what the checkbox means.
|
|
218
|
+
if (saveProfile) {
|
|
219
|
+
saveBuyerContact?.({ name: buyer.name, phone: buyer.phone, taxId: buyer.taxId });
|
|
220
|
+
}
|
|
221
|
+
setStep("payment");
|
|
222
|
+
}, [buyer.name, buyer.phone, buyer.taxId, saveProfile, taxIdOnFile, clearError, saveBuyerContact]);
|
|
223
|
+
const startPayment = useCallback(async (chosen: PaymentMethod, override?: BuyerInfo) => {
|
|
224
|
+
clearError();
|
|
225
|
+
setCreating(true);
|
|
226
|
+
const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
|
|
227
|
+
setCreating(false);
|
|
228
|
+
if (!result.ok) { setCreateError(result.error.message); setErrorField(result.error.field); return; }
|
|
229
|
+
if (handOverToProvider(result.data)) return;
|
|
230
|
+
setOrder(result.data);
|
|
231
|
+
setFinalStatus(null);
|
|
232
|
+
}, [buyer, saveProfile, createOrder, clearError]);
|
|
233
|
+
const payWithEmail = useCallback((email: string) => {
|
|
234
|
+
if (!method) return;
|
|
235
|
+
const next = { ...buyer, email };
|
|
236
|
+
setBuyerState(next);
|
|
237
|
+
void startPayment(method, next);
|
|
238
|
+
}, [buyer, method, startPayment]);
|
|
239
|
+
const handleResolved = useCallback((s: OrderStatus) => { setFinalStatus(s); setStep("status"); }, []);
|
|
240
|
+
const retry = useCallback(() => {
|
|
241
|
+
setOrder(null); setFinalStatus(null); clearError(); setStep("payment");
|
|
242
|
+
}, [clearError]);
|
|
243
|
+
const completed = useMemo(() => new Set(STEP_ORDER.slice(0, STEP_ORDER.indexOf(step))), [step]);
|
|
244
|
+
usePaidPort(finalStatus ?? resume.status, onPaid);
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
step, setStep, method, setMethod, buyer, setBuyer, saveProfile, setSaveProfile,
|
|
248
|
+
order, finalStatus: finalStatus ?? resume.status, creating, createError, errorField,
|
|
249
|
+
goToMenu: onExitToMenu, back, editBuyer,
|
|
250
|
+
goToPayment, startPayment, payWithEmail, handleResolved, retry, completed,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { pollOrderStatus } from "./client";
|
|
4
|
+
import { TERMINAL_STATUSES, type OrderStatus } from "./types";
|
|
5
|
+
|
|
6
|
+
interface PollingOptions {
|
|
7
|
+
/** Delay between polls (ms). */
|
|
8
|
+
intervalMs?: number;
|
|
9
|
+
/** Poll only while `true` (e.g. after a card charge is submitted). */
|
|
10
|
+
enabled?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Opt-in bound on successful non-terminal polls (FUT-191): stop scheduling and
|
|
13
|
+
* report `timedOut` once this many healthy AWAITING responses arrive.
|
|
14
|
+
* Undefined ⇒ unbounded, today's behavior (the PIX consumer passes no cap).
|
|
15
|
+
*/
|
|
16
|
+
maxHealthyPolls?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Consecutive poll errors tolerated before giving up (avoids an infinite spinner). */
|
|
20
|
+
const MAX_POLL_ERRORS = 4;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Poll an order's payment status until it reaches a terminal state.
|
|
24
|
+
*
|
|
25
|
+
* Uses a self-scheduling `setTimeout` (never overlapping requests) that stops as
|
|
26
|
+
* soon as the order is PAID/FAILED/EXPIRED, and tears down on unmount or when the
|
|
27
|
+
* order id changes. This is the client half of the async confirmation the
|
|
28
|
+
* PagSeguro webhook drives on the server.
|
|
29
|
+
*/
|
|
30
|
+
export function usePaymentPolling(
|
|
31
|
+
orderId: string | null,
|
|
32
|
+
{ intervalMs = 2500, enabled = true, maxHealthyPolls }: PollingOptions = {},
|
|
33
|
+
): { status: OrderStatus | null; error: string | null; timedOut: boolean } {
|
|
34
|
+
const [status, setStatus] = useState<OrderStatus | null>(null);
|
|
35
|
+
const [error, setError] = useState<string | null>(null);
|
|
36
|
+
const [timedOut, setTimedOut] = useState(false);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!orderId || !enabled) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let cancelled = false;
|
|
44
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
45
|
+
let errorCount = 0;
|
|
46
|
+
let healthyCount = 0;
|
|
47
|
+
setTimedOut(false);
|
|
48
|
+
setError(null);
|
|
49
|
+
|
|
50
|
+
const tick = async (): Promise<void> => {
|
|
51
|
+
const result = await pollOrderStatus(orderId);
|
|
52
|
+
if (cancelled) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (result.ok) {
|
|
56
|
+
errorCount = 0;
|
|
57
|
+
setStatus(result.data);
|
|
58
|
+
if (TERMINAL_STATUSES.includes(result.data)) {
|
|
59
|
+
return; // terminal — stop polling
|
|
60
|
+
}
|
|
61
|
+
healthyCount += 1;
|
|
62
|
+
if (maxHealthyPolls !== undefined && healthyCount >= maxHealthyPolls) {
|
|
63
|
+
// A healthy-but-still-AWAITING stream never trips the error cap, so
|
|
64
|
+
// bound it separately (FUT-191): stop scheduling and let the consumer
|
|
65
|
+
// show a "taking longer" state instead of an infinite spinner.
|
|
66
|
+
setTimedOut(true);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
errorCount += 1;
|
|
71
|
+
if (errorCount >= MAX_POLL_ERRORS) {
|
|
72
|
+
// Give up rather than spin forever; surface the error to the consumer.
|
|
73
|
+
setError(result.error);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
timer = setTimeout(() => {
|
|
78
|
+
void tick();
|
|
79
|
+
}, intervalMs);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
void tick();
|
|
83
|
+
|
|
84
|
+
return () => {
|
|
85
|
+
cancelled = true;
|
|
86
|
+
if (timer) {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}, [orderId, intervalMs, enabled, maxHealthyPolls]);
|
|
91
|
+
|
|
92
|
+
return { status, error, timedOut };
|
|
93
|
+
}
|