@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.
Files changed (42) hide show
  1. package/package.json +7 -4
  2. package/src/__tests__/provider-priority-list.test.tsx +2 -2
  3. package/src/__tests__/slugged-provider.test.tsx +108 -0
  4. package/src/card/cpf.ts +42 -0
  5. package/src/card/fields.tsx +254 -0
  6. package/src/card/format.ts +103 -0
  7. package/src/card/index.ts +42 -0
  8. package/src/card/stripe-token.ts +81 -0
  9. package/src/card/tokenize.test.ts +194 -0
  10. package/src/card/tokenize.ts +327 -0
  11. package/src/card/types.ts +54 -0
  12. package/src/components/PaymentProviderSettings.tsx +30 -4
  13. package/src/components/checkout/__tests__/card-3ds-handover.test.tsx +147 -0
  14. package/src/components/checkout/__tests__/clear-cart-on-paid.test.tsx +64 -0
  15. package/src/components/checkout/__tests__/hosted-return.test.ts +109 -0
  16. package/src/components/checkout/__tests__/method-capability.test.tsx +120 -0
  17. package/src/components/checkout/__tests__/payments-unavailable.test.tsx +53 -0
  18. package/src/components/checkout/__tests__/save-on-continue.test.tsx +165 -0
  19. package/src/components/checkout/__tests__/second-host.test.tsx +86 -0
  20. package/src/components/checkout/buyer-info-form.tsx +138 -0
  21. package/src/components/checkout/card-view.tsx +128 -0
  22. package/src/components/checkout/checkout-flow.tsx +201 -0
  23. package/src/components/checkout/checkout-steps.tsx +366 -0
  24. package/src/components/checkout/client.ts +157 -0
  25. package/src/components/checkout/hosted-return.ts +92 -0
  26. package/src/components/checkout/icons.tsx +61 -0
  27. package/src/components/checkout/method-capability.ts +69 -0
  28. package/src/components/checkout/method-picker.tsx +153 -0
  29. package/src/components/checkout/mui-defaults.tsx +218 -0
  30. package/src/components/checkout/payer-summary.tsx +81 -0
  31. package/src/components/checkout/payment-status.tsx +256 -0
  32. package/src/components/checkout/payments-unavailable.tsx +79 -0
  33. package/src/components/checkout/pix-view.tsx +179 -0
  34. package/src/components/checkout/types.ts +223 -0
  35. package/src/components/checkout/ui.tsx +171 -0
  36. package/src/components/checkout/use-card-checkout.ts +346 -0
  37. package/src/components/checkout/use-checkout-controller.ts +252 -0
  38. package/src/components/checkout/use-payment-polling.ts +93 -0
  39. package/src/components/settings-state.ts +45 -2
  40. package/src/index.ts +74 -1
  41. package/src/result.ts +11 -0
  42. 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
+ }